diff --git a/app/src/lib/fakeBots.ts b/app/src/lib/fakeBots.ts index f7e20ff0..706f1807 100644 --- a/app/src/lib/fakeBots.ts +++ b/app/src/lib/fakeBots.ts @@ -33,6 +33,7 @@ export function botHandlers(fake: FakeBotloft): Pick { color: color ?? AVATAR_PALETTE[index % AVATAR_PALETTE.length] ?? "#FF7A59", paused: false, permissionMode: "default", + agent: "claude", model: model ?? "default", modelInUse: null, effort: "default", diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index dab73383..09ecdc5f 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -137,6 +137,11 @@ export type BotState = "offline" | "launching" | "idle" | "busy" | "needs_approv */ export type PermissionMode = "default" | "accept_edits" | "plan" | "auto" | "bypass_permissions"; +/** + * Which agent runs a bot (spec 30). Chosen when the bot is created. + */ +export type AgentKind = "claude" | "codex" | "agy"; + /** * Which Claude model a bot runs on (spec 7.4): Claude Code's `--model` * aliases, or the account's default. @@ -233,7 +238,11 @@ slug: string, role: string, instructions: string, /** * Avatar color, `#RRGGBB`. */ -color: string, paused: boolean, permissionMode: PermissionMode, model: BotModel, +color: string, paused: boolean, permissionMode: PermissionMode, +/** + * The agent behind the bot. + */ +agent: AgentKind, model: BotModel, /** * The model id Claude Code reported when the bot last started a turn * (`claude-opus-5-5`); `null` before its first turn. @@ -446,7 +455,11 @@ color?: string, /** * The account's default when absent. */ -model?: BotModel, }; +model?: BotModel, +/** + * `claude` when absent. + */ +agent?: AgentKind, }; /** * Fields left out stay unchanged. diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 46b6505e..f294d368 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -127,6 +127,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); diff --git a/crates/botloft-core/src/protocol/methods.rs b/crates/botloft-core/src/protocol/methods.rs index f7434437..68028e05 100644 --- a/crates/botloft-core/src/protocol/methods.rs +++ b/crates/botloft-core/src/protocol/methods.rs @@ -4,7 +4,7 @@ use std::fmt; use serde::{Deserialize, Serialize}; -use super::{BotModel, DeliveryBacklog, PermissionMode}; +use super::{AgentKind, BotModel, DeliveryBacklog, PermissionMode}; use crate::ids::{BotId, CrewId}; /// Identifies the connecting app in `session.hello`. @@ -235,6 +235,10 @@ pub struct BotsCreateParams { #[serde(default)] #[cfg_attr(test, ts(optional))] pub model: Option, + /// `claude` when absent. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub agent: Option, } /// Fields left out stay unchanged. diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs index 2b55a37b..f9645e57 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -90,6 +90,25 @@ impl PermissionMode { } } +text_enum!( + /// Which agent runs a bot (spec 30). Chosen when the bot is created. + AgentKind, "agent" { + /// Claude Code. + Claude => "claude", + /// OpenAI's Codex CLI. + Codex => "codex", + /// Google's Antigravity CLI (`agy`). + Agy => "agy", + } +); + +impl AgentKind { + /// Whether the daemon can run bots of this agent yet. + pub fn runs(self) -> bool { + matches!(self, Self::Claude) + } +} + text_enum!( /// Which Claude model a bot runs on (spec 7.4): Claude Code's `--model` /// aliases, or the account's default. @@ -135,6 +154,8 @@ pub struct Bot { pub color: String, pub paused: bool, pub permission_mode: PermissionMode, + /// The agent behind the bot. + pub agent: AgentKind, pub model: BotModel, /// The model id Claude Code reported when the bot last started a turn /// (`claude-opus-5-5`); `null` before its first turn. diff --git a/crates/botloft-store/migrations/0026_bot_agent.sql b/crates/botloft-store/migrations/0026_bot_agent.sql new file mode 100644 index 00000000..3af81c9a --- /dev/null +++ b/crates/botloft-store/migrations/0026_bot_agent.sql @@ -0,0 +1,2 @@ +-- Which agent runs a bot (spec 30): `claude`, `codex` or `agy`. +ALTER TABLE bots ADD COLUMN agent TEXT NOT NULL DEFAULT 'claude'; diff --git a/crates/botloft-store/src/bots.rs b/crates/botloft-store/src/bots.rs index e63a62ef..a700139d 100644 --- a/crates/botloft-store/src/bots.rs +++ b/crates/botloft-store/src/bots.rs @@ -1,7 +1,7 @@ //! `bots` table. use botloft_core::ids::{BotId, CrewId}; -use botloft_core::protocol::{BotEffort, BotModel, ModelEffort, PermissionMode}; +use botloft_core::protocol::{AgentKind, BotEffort, BotModel, ModelEffort, PermissionMode}; use rusqlite::{OptionalExtension, Row, params}; use crate::{Result, Store, cached_execute, cached_row, parse_column, unique_as_duplicate}; @@ -20,6 +20,7 @@ pub struct BotRecord { pub color: String, pub paused: bool, pub permission_mode: PermissionMode, + pub agent: AgentKind, pub model: BotModel, /// What Claude Code last reported; written by [`Store::set_model_in_use`] /// and left alone by [`Store::update_bot`]. @@ -33,7 +34,7 @@ pub struct BotRecord { } const COLUMNS: &str = "id, crew_id, name, handle, slug, role, instructions, color, paused, created_at, archived_at, \ - permission_mode, model, model_in_use, effort, effort_default"; + permission_mode, model, model_in_use, effort, effort_default, agent"; fn from_row(row: &Row<'_>) -> rusqlite::Result { Ok(BotRecord { @@ -56,6 +57,7 @@ fn from_row(row: &Row<'_>) -> rusqlite::Result { effort_default: row .get::<_, Option>(15)? .and_then(|level| level.parse().ok()), + agent: parse_column(row, 16)?, }) } @@ -65,7 +67,7 @@ impl Store { .execute( &format!( "INSERT INTO bots ({COLUMNS}) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)" ), params![ bot.id.as_str(), @@ -83,7 +85,8 @@ impl Store { bot.model.as_str(), bot.model_in_use, bot.effort.as_str(), - bot.effort_default.map(ModelEffort::as_str) + bot.effort_default.map(ModelEffort::as_str), + bot.agent.as_str() ], ) .map_err(|err| unique_as_duplicate(err, "bot handle or slug"))?; diff --git a/crates/botloft-store/src/bots/tests.rs b/crates/botloft-store/src/bots/tests.rs index 61e1e64e..881777a8 100644 --- a/crates/botloft-store/src/bots/tests.rs +++ b/crates/botloft-store/src/bots/tests.rs @@ -33,6 +33,7 @@ fn bot(crew: &CrewId, handle: &str, created_at: i64) -> BotRecord { color: "#FF7A59".to_owned(), paused: false, permission_mode: PermissionMode::Default, + agent: AgentKind::Claude, model: BotModel::Default, model_in_use: None, effort: BotEffort::Default, diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index 333e54db..acb3f08c 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -195,6 +195,7 @@ pub(crate) mod tests { color: "#FF7A59".to_owned(), paused: false, permission_mode: botloft_core::protocol::PermissionMode::Default, + agent: botloft_core::protocol::AgentKind::Claude, model: botloft_core::protocol::BotModel::Default, model_in_use: None, effort: botloft_core::protocol::BotEffort::Default, diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs index e76e64de..dae21248 100644 --- a/crates/botloft-store/src/migrate.rs +++ b/crates/botloft-store/src/migrate.rs @@ -32,6 +32,7 @@ const MIGRATIONS: &[(u32, &str)] = &[ (23, include_str!("../migrations/0023_plan_share.sql")), (24, include_str!("../migrations/0024_mcp_servers.sql")), (25, include_str!("../migrations/0025_crew_color.sql")), + (26, include_str!("../migrations/0026_bot_agent.sql")), ]; /// Schema version after every migration has run. diff --git a/crates/botloftd/src/service/bots.rs b/crates/botloftd/src/service/bots.rs index 6f6aaaee..31929977 100644 --- a/crates/botloftd/src/service/bots.rs +++ b/crates/botloftd/src/service/bots.rs @@ -2,7 +2,7 @@ use botloft_core::ids::{BotId, CrewId}; use botloft_core::protocol::{ - Bot, BotEffort, BotIdParams, BotModel, BotState, BotsCreateParams, BotsListParams, + AgentKind, Bot, BotEffort, BotIdParams, BotModel, BotState, BotsCreateParams, BotsListParams, BotsRestartParams, BotsSetPausedParams, BotsUpdateParams, Crew, PermissionMode, }; use botloft_core::{avatar, now_ms, slug, validate}; @@ -38,6 +38,7 @@ pub fn create(daemon: &Daemon, params: BotsCreateParams) -> ApiResult { ¶ms.instructions, params.color, params.model, + params.agent, )?; let store = daemon.store(); let crew = crews::active(&store, ¶ms.crew_id)?; @@ -53,6 +54,7 @@ pub(crate) struct NewBot { pub role: String, pub instructions: String, pub color: Option, + pub agent: AgentKind, pub model: BotModel, pub effort: BotEffort, } @@ -64,12 +66,21 @@ impl NewBot { instructions: &str, color: Option, model: Option, + agent: Option, ) -> ApiResult { + let agent = agent.unwrap_or(AgentKind::Claude); + if !agent.runs() { + return Err(ApiError::validation(format!( + "bots that run on {} are not available yet", + agent.as_str() + ))); + } Ok(Self { name: validate::name("name", name)?, role: validate::role(role)?, instructions: validate::instructions(instructions)?, color: color.as_deref().map(parse_color).transpose()?, + agent, model: model.unwrap_or(BotModel::Default), effort: BotEffort::Default, }) @@ -109,6 +120,7 @@ pub(crate) fn insert( color, paused: false, permission_mode: PermissionMode::Default, + agent: new.agent, model: new.model, model_in_use: None, effort: new.effort, @@ -231,6 +243,7 @@ pub(crate) fn to_protocol(daemon: &Daemon, store: &Store, crew: &Crew, record: B color: record.color, paused: record.paused, permission_mode: record.permission_mode, + agent: record.agent, model: record.model, model_in_use: record.model_in_use, effort: record.effort, diff --git a/crates/botloftd/src/service/catalog.rs b/crates/botloftd/src/service/catalog.rs index 50ff59eb..168475a8 100644 --- a/crates/botloftd/src/service/catalog.rs +++ b/crates/botloftd/src/service/catalog.rs @@ -29,6 +29,7 @@ pub fn add(daemon: &Daemon, params: CatalogAddParams) -> ApiResult { &template.instructions, None, Some(template.model), + None, )?; new.effort = template.effort; let store = daemon.store(); diff --git a/crates/botloftd/src/service/crews.rs b/crates/botloftd/src/service/crews.rs index 79d106d3..aaa26ee6 100644 --- a/crates/botloftd/src/service/crews.rs +++ b/crates/botloftd/src/service/crews.rs @@ -38,7 +38,16 @@ pub fn create(daemon: &Daemon, params: CrewsCreateParams) -> ApiResult { let lead = params .lead - .map(|lead| NewBot::check(&lead.name, &lead.role, &lead.instructions, None, lead.model)) + .map(|lead| { + NewBot::check( + &lead.name, + &lead.role, + &lead.instructions, + None, + lead.model, + None, + ) + }) .transpose()?; let mut crew = Crew { diff --git a/crates/botloftd/src/tools/suggest.rs b/crates/botloftd/src/tools/suggest.rs index 588c356e..18d09647 100644 --- a/crates/botloftd/src/tools/suggest.rs +++ b/crates/botloftd/src/tools/suggest.rs @@ -199,6 +199,7 @@ fn check(suggestion: &Suggestion) -> Result { &suggestion.instructions, None, model, + None, ) .map_err(explain) } diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index 9d51c361..4095c72f 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -310,6 +310,7 @@ mod tests { color: "#FF7A59".to_owned(), paused: false, permission_mode: botloft_core::protocol::PermissionMode::Default, + agent: botloft_core::protocol::AgentKind::Claude, model: botloft_core::protocol::BotModel::Default, model_in_use: None, effort: botloft_core::protocol::BotEffort::Default, diff --git a/crates/botloftd/src/workspace/mod.rs b/crates/botloftd/src/workspace/mod.rs index b609a63d..0ecdf067 100644 --- a/crates/botloftd/src/workspace/mod.rs +++ b/crates/botloftd/src/workspace/mod.rs @@ -160,6 +160,7 @@ mod tests { color: "#FF7A59".to_owned(), paused: false, permission_mode: botloft_core::protocol::PermissionMode::Default, + agent: botloft_core::protocol::AgentKind::Claude, model: botloft_core::protocol::BotModel::Default, model_in_use: None, effort: botloft_core::protocol::BotEffort::Default, diff --git a/crates/botloftd/tests/common/supervised.rs b/crates/botloftd/tests/common/supervised.rs index 1bfd01c5..14d24c1a 100644 --- a/crates/botloftd/tests/common/supervised.rs +++ b/crates/botloftd/tests/common/supervised.rs @@ -44,6 +44,7 @@ pub async fn setup() -> Setup { instructions: String::new(), color: None, model: None, + agent: None, }, ) .expect("bot"); diff --git a/crates/botloftd/tests/crew_folders.rs b/crates/botloftd/tests/crew_folders.rs index 61c25e81..fd87afa1 100644 --- a/crates/botloftd/tests/crew_folders.rs +++ b/crates/botloftd/tests/crew_folders.rs @@ -35,6 +35,7 @@ fn bot(parts: &Parts, crew: &Crew) -> botloft_core::protocol::Bot { instructions: String::new(), color: None, model: None, + agent: None, }, ) .expect("bot") diff --git a/crates/botloftd/tests/routine_signals.rs b/crates/botloftd/tests/routine_signals.rs index 51a3aad6..589ab2d3 100644 --- a/crates/botloftd/tests/routine_signals.rs +++ b/crates/botloftd/tests/routine_signals.rs @@ -36,6 +36,7 @@ fn writer(s: &Setup) -> botloft_core::ids::BotId { instructions: String::new(), color: None, model: None, + agent: None, }, ) .expect("writer") @@ -135,6 +136,7 @@ async fn only_enabled_routines_of_the_crew_hear_a_signal() { instructions: String::new(), color: None, model: None, + agent: None, }, ) .expect("bot"); diff --git a/crates/botloftd/tests/rpc_crews_bots.rs b/crates/botloftd/tests/rpc_crews_bots.rs index 4f363ff2..0169440f 100644 --- a/crates/botloftd/tests/rpc_crews_bots.rs +++ b/crates/botloftd/tests/rpc_crews_bots.rs @@ -168,6 +168,38 @@ async fn updates_refresh_the_rules_and_keep_the_bot_memory() { assert_eq!(crew["paused"], true); } +#[tokio::test] +async fn a_bot_has_an_agent_and_only_claude_runs_yet() { + let daemon = TestDaemon::start().await; + let mut app = daemon.session().await; + let crew = app + .call("crews.create", json!({ "name": "Docs" })) + .await + .expect("crew"); + let make = |agent: serde_json::Value| json!({ "crewId": crew["id"], "name": "Scout", "role": "", "instructions": "", "agent": agent }); + let bot = app + .call("bots.create", make(json!("claude"))) + .await + .expect("claude bot"); + assert_eq!(bot["agent"], "claude"); + for other in ["codex", "agy"] { + let err = app + .call("bots.create", make(json!(other))) + .await + .expect_err("not available yet"); + assert_eq!(err.code, VALIDATION); + assert!(err.message.contains("not available yet"), "{err:?}"); + } + let plain = app + .call( + "bots.create", + json!({ "crewId": crew["id"], "name": "Writer", "role": "", "instructions": "" }), + ) + .await + .expect("default bot"); + assert_eq!(plain["agent"], "claude"); +} + #[tokio::test] async fn archiving_a_crew_archives_its_bots() { let daemon = TestDaemon::start().await; diff --git a/crates/botloftd/tests/supervisor.rs b/crates/botloftd/tests/supervisor.rs index 07cc70be..c74c04d7 100644 --- a/crates/botloftd/tests/supervisor.rs +++ b/crates/botloftd/tests/supervisor.rs @@ -211,6 +211,7 @@ async fn a_failed_spawn_backs_off_and_retries() { instructions: String::new(), color: None, model: None, + agent: None, }; let bot = bots::create(&daemon, params).expect("bot"); tokio::spawn(supervisor::run(Arc::clone(&daemon))); diff --git a/crates/botloftd/tests/supervisor_launch.rs b/crates/botloftd/tests/supervisor_launch.rs index f604c01a..71816400 100644 --- a/crates/botloftd/tests/supervisor_launch.rs +++ b/crates/botloftd/tests/supervisor_launch.rs @@ -34,6 +34,7 @@ fn add_bot(daemon: &Daemon) -> BotId { instructions: String::new(), color: None, model: None, + agent: None, }, ) .expect("bot") diff --git a/docs/adr/0003-agentes.md b/docs/adr/0003-agentes.md new file mode 100644 index 00000000..0263e247 --- /dev/null +++ b/docs/adr/0003-agentes.md @@ -0,0 +1,62 @@ +# ADR 0003: mais de um agente de código por trás de um bot (Codex e Antigravity) + +Status: proposto (2026-10-09). Estende o ADR 0001: o bot continua headless e com chat, mas o processo por trás dele deixa de ser sempre o Claude Code. + +## Contexto + +Todo bot roda `claude -p` com `stream-json` (ADR 0001). O dono quer escolher o agente de cada bot, começando por **Codex** (OpenAI) e **Antigravity** (`agy`, Google), usando as assinaturas e os logins que ele já tem. A meta é paridade total com o bot Claude: chat, mensagens entre bots, MCP do Botloft, aprovações, rotinas, navegador, desktop, perguntas ao dono, contexto e uso. + +O mapa do acoplamento atual (código lido em 2026-10-09) mostra que o Claude não está só atrás do `Runtime`, que abstrai apenas processo, bytes e kill: + +- **Entrada e saída:** o texto do dono vira uma linha `{"type":"user",...}` (courier); a saída é lida por `chat/events.rs`, `chat/control.rs`, `chat/account.rs` e `chat/session.rs`, que conhecem `system/init`, `stream_event`, `assistant`, `user` com `isReplay`, `result`, `rate_limit_event` e `control_response`. O recibo de leitura e a fila `waiting` dependem do eco `isReplay`. +- **Aprovações:** `--permission-prompt-tool` aponta para a tool MCP `permission_prompt`; `approvals/always.rs`, `core/chat.rs` e o cartão de plano (`ExitPlanMode`) conhecem os nomes e os formatos de entrada das ferramentas do Claude. +- **Isolamento entre crews:** `.claude/settings.json` (regras `deny`, `claudeMdExcludes`), `--setting-sources`, `CLAUDE.md` como memória. +- **Contexto, uso e custo:** `get_context_usage`, `rate_limit_event`, `total_cost_usd`. +- **Dados e UI:** `BotModel`, `BotEffort`, `PermissionMode`, `ClaudeAccount` no protocolo; `bots.model`, `effort`, `session_id` no banco; listas de modelos nos diálogos do app. + +## O que foi visto nos dois agentes (2026-10-09) + +**Antigravity `agy` 1.3.1** (já instalado aqui): + +- Tem `--print`, `--input-format stream-json --output-format stream-json`, `--model`, `--effort`, `--mode` (`accept-edits`, `plan`), `--add-dir`, `--continue`, `--conversation `, `--sandbox`, `agy mcp add` e `agy models`. +- O protocolo é **próprio**, não o do Claude. Saída: uma linha JSON por evento, com `"event"`: `init` (traz `conversation_id`, `model`, `cwd`, a lista de `tools` e `permission_mode`), `step_update` (`step_type` `user_input` ou `agent_response`, `state` `ACTIVE` ou `DONE`, `text_delta`, `usage`) e `result` (`status`, `response`, `usage`). Entrada: `{"event":"user","message":{"role":"user","content":[{"type":"text","text":"..."}]}}`; sem o campo `event` ou sem `content` a CLI responde com erro explícito. +- Um turno de teste (`gemini-3.8-flash-low`, "Say only: pong") fechou com `result.status` `SUCCESS` e `usage` de tokens. +- Não visto ainda: vários turnos no mesmo processo, ferramentas e o evento de pedido de aprovação (`ask_permission` está na lista de ferramentas, `permission_mode` veio `request-review`), formato de uma tool MCP por HTTP, retomada com `--conversation`. + +**Codex `codex-cli` 0.148.0-alpha.9** (instalado com o app do Codex): + +- `codex exec --json` roda **um turno** e emite JSONL: `thread.started`, `turn.started`, `item.completed` (`agent_message`), `turn.completed` com `usage`. Serve para tarefas isoladas, não para um bot que fica ligado. +- `codex app-server` (experimental) fala JSON-RPC e gera o esquema do protocolo (`generate-json-schema`): pedidos de aprovação tipados (`CommandExecutionRequestApprovalParams`, `FileChangeRequestApprovalParams`, `PermissionsRequestApprovalParams`, `McpServerElicitationRequestParams`) e chamadas de ferramenta dinâmicas (`DynamicToolCallParams`). É o canal que serve ao Botloft: conversa contínua e aprovações que o daemon responde. +- Tem `--add-dir`, `-s read-only|workspace-write|danger-full-access`, `codex mcp`, `codex login`, `-c model=...`, `model_reasoning_effort` no `config.toml`, `AGENTS.md` como arquivo de instruções. + +## Decisão + +1. **Um bot tem um agente** (`claude`, `codex`, `agy`), escolhido ao criar e gravado em `bots.agent` (padrão `claude`). Trocar de agente num bot que já existe fica fora da primeira versão: a sessão e o histórico de cada agente são dele. +2. **Uma interface `Agent` no daemon** é dona de tudo que é específico de um agente, e o resto do daemon só vê eventos normalizados: + - **Descoberta e saúde:** onde está o binário, versão mínima, estado do login (`auth_status`) e o comando de entrar. + - **Lançamento:** argumentos, ambiente e os arquivos que o agente lê na pasta do bot (instruções, regras, configuração de MCP, regras de isolamento). + - **Entrada:** como um turno do dono, de outro bot ou do daemon entra, com imagens, e como o recibo de leitura é obtido. + - **Saída:** um decodificador com estado, de bytes para o enum normalizado: turno começou, resposta, ferramenta (início e fim), turno acabou (tokens, custo se houver), contexto, limite de uso, falha de login, sessão iniciada ou perdida, subagentes, modelo em uso. + - **Aprovações:** converte o pedido do agente para o `{tool_name, input, tool_use_id}` comum e leva a resposta de volta. + - **Capacidades:** modelos e esforços que oferece, modos de permissão, vocabulário de ferramentas (comando, edição de arquivo, saída do plano, web) e o que ele não faz. +3. **O Claude vira a primeira implementação**, sem mudar de comportamento. O `Runtime` continua sendo a camada de processo, e o `FakeRuntime` passa a ser dirigido por eventos do agente em teste. +4. **Modelo e esforço viram texto validado pelas capacidades do agente** (hoje são enums do Claude no protocolo e no banco). O app monta os seletores a partir do que `agents.list` informa, não de listas fixas. +5. **O que um agente não faz aparece desligado, não escondido.** Contexto exato, custo e uso do plano são opcionais por agente; o app mostra o que houver e diz o que falta. +6. **Isolamento é requisito de lançamento.** Um agente só roda bots quando o Botloft consegue impor o que o `settings.json` impõe no Claude (spec 7.5): sem ler as outras crews nem os dados do Botloft. Cada agente usa o seu mecanismo (sandbox do Codex com `--add-dir`; `--sandbox` e `--add-dir` no `agy`) e a spec registra o que foi testado. Sem isso comprovado, o agente fica marcado como experimental e desligado por padrão. +7. **Protocolo do Codex:** `app-server` por JSON-RPC, não `exec --json` por turno. **Protocolo do AGY:** `stream-json` próprio do `agy`. +8. **Nada copiado de outros projetos.** Fontes: a documentação e o `--help` de cada CLI, o esquema gerado por `codex app-server generate-json-schema` e testes reais, registrados na seção 30 da spec. + +## Ordem do trabalho + +1. **A0 (este PR):** ADR, seção 30 da spec, `bots.agent` no banco e no protocolo (só `claude` roda; os outros dois são recusados com mensagem clara). +2. **A1:** a interface `Agent`, com o Claude por trás dela e os testes de hoje passando sem mudança. +3. **A2:** AGY (o protocolo mais próximo do atual), com modelos, esforço, MCP, aprovações e isolamento testados com o `agy` real. +4. **A3:** Codex por `app-server`. +5. **A4:** o seletor de agente no app, a conta e o login de cada agente, e a paridade (rotinas, navegador, desktop, perguntas) conferida agente por agente. + +## Consequências + +- O trabalho é grande e atravessa protocolo, banco, daemon e app. Cada passo entra junto com código e testes, nunca como PR só de documento. +- O contador de contexto exato, o custo e a fatia do plano são do Claude hoje. Nos outros agentes ficam aproximados ou ausentes até haver fonte oficial. +- O texto da interface fala do "agente" do bot pelo nome do bot, como já fazemos, e não de "Claude". +- Os pontos acima marcados como "não visto" entram na seção 30 e precisam de teste real antes de o agente correspondente ser liberado. diff --git a/docs/spec.md b/docs/spec.md index cf24dec5..beeaadd1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -690,7 +690,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `crews.delete` | `crewId, recycleFolder?` | `{crewId}`; exclui a crew, ativa ou arquivada, com todos os bots dela; com `recycleFolder`, a pasta dela vai depois para a Lixeira (7.6) | | `bots.list` | `crewId?` | `Bot[]` | | `archive.list` | | `{crews, bots}`: o que está arquivado, que `crews.list` e `bots.list` deixam de fora, do arquivado mais recente ao mais antigo. `bots` traz todos os bots arquivados, também os das crews arquivadas (7.6) | -| `bots.create` | `crewId, name, role, instructions, color?, model?` | `Bot` | +| `bots.create` | `crewId, name, role, instructions, color?, model?, agent?` | `Bot` (`agent` é `claude`, `codex` ou `agy`; só `claude` roda por enquanto, os outros dão `-32004`: seção 30) | | `catalog.list` | `category?` | `BotTemplate[]`: os modelos de bot do catálogo, sem as instruções (26.4) | | `catalog.get` | `id` | `BotTemplateFull`: o modelo com `instructions`, `model` e `effort` (26.4) | | `catalog.add` | `crewId, templateId, name, role` | `Bot`: cria na equipe um bot a partir do modelo (26.4) | @@ -754,7 +754,7 @@ Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_time | Tabela | Colunas principais | |---|---| | `crews` | `id, name, slug, paused, work_dir, lead_bot_id, color, created_at, archived_at` (`work_dir` é a pasta escolhida; `NULL` usa a `shared`. `lead_bot_id` é o chefe, sem chave estrangeira: ele é gravado junto com a crew. `color` é o `#RRGGBB` que o dono escolheu, `NULL` sem cor) | -| `bots` | `id, crew_id, name, handle, slug, role, instructions, color, paused, permission_mode, model, model_in_use, effort, effort_default, token_hash, session_id, created_at, archived_at` (`effort_default` é o nível do próprio modelo, como o Claude Code disse: `low` a `max`, `none` ou `NULL`, 7.4) | +| `bots` | `id, crew_id, name, handle, slug, role, instructions, color, paused, permission_mode, agent, model, model_in_use, effort, effort_default, token_hash, session_id, created_at, archived_at` (`effort_default` é o nível do próprio modelo, como o Claude Code disse: `low` a `max`, `none` ou `NULL`, 7.4) | | `messages` | `id, crew_id, from_kind (owner/bot/system), from_bot_id, to_bot_id, kind (note/task/result/system), body, task_id, created_at`; `reply_item_id` e `reply_text` quando o dono respondeu a algo do chat (9.3; sem chave estrangeira, porque os itens saem antes das messages na exclusão, 7.6) | | `attachments` | `id, message_id, name, media_type, size, path, created_at` | | `deliveries` | `id, message_id, bot_id, state, attempts, next_attempt_at, lease_until, last_error, sent_generation, turn_uuid, read_at, updated_at` | @@ -2633,3 +2633,37 @@ O dono não precisa abrir cada bot para saber como foi o dia. O **resumo diário | **RD1** Ferramenta | `crew_activity`: contagens, amostra, esperas do dono, rotinas; só o chefe; limites da janela | `crew_activity.rs`: um chefe e um bot, uma task concluída e uma aberta; uma pergunta ao dono contada; outro bot e `hours` fora de 1 a 168 recusados; `mcp.rs` com a ferramenta na lista | | **RD2** Cartão | o cartão "Resumo diário" na aba de rotinas da equipe, ligar, desligar, mudar, três idiomas | `FakeBotloft`: criar para o chefe todo dia no horário escolhido com o pedido que cita a ferramenta, desligar e ligar sem criar outra, equipe sem chefe sem o cartão; `pnpm check` | | **RD3** Teste real | o resumo com o Claude Code real numa equipe de teste | **A verificar** (seção 19): o chefe chama `crew_activity`, escreve o resumo no idioma do dono e não começa trabalho novo; a rotina "agora" fecha como `done` | + +## 30. Agentes (Codex e Antigravity) + +Decisão e contexto: `docs/adr/0003-agentes.md`. Um bot tem um **agente**, `Bot.agent` (`claude`, `codex` ou `agy`), escolhido ao criar e guardado em `bots.agent` (padrão `claude`; migration 0026). O dono usa a assinatura e o login que já tem em cada agente. A meta é paridade total com o bot Claude, entregue por passos. + +### 30.1 Estado + +| Passo | O que entra | Estado | +|---|---|---| +| **A0** | ADR, esta seção, `bots.agent` no banco e no protocolo; `bots.create` com `codex` ou `agy` dá `-32004` ("not available yet") | feito | +| **A1** | A interface `Agent` no daemon, com o Claude por trás e os testes de hoje sem mudança | a fazer | +| **A2** | AGY: lançamento, entrada e saída, MCP, aprovações, isolamento, modelos e esforço, testados com o `agy` real | a fazer | +| **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 | + +### 30.2 O que a interface `Agent` possui + +Descoberta e saúde (binário, versão mínima, login, comando de entrar); lançamento (argumentos, ambiente, arquivos da pasta do bot: instruções, regras, configuração de MCP, isolamento); entrada (turno do dono, de outro bot ou do daemon, imagens, recibo de leitura); saída (decodificador com estado para eventos normalizados: turno começou, resposta, ferramenta, turno acabou, contexto, limite, falha de login, sessão iniciada ou perdida, subagentes, modelo em uso); aprovações (pedido no formato comum `{tool_name, input, tool_use_id}` e a resposta de volta); capacidades (modelos, esforços, modos de permissão, vocabulário de ferramentas). Modelo e esforço passam a ser texto validado pelas capacidades do agente. O que um agente não faz aparece desligado no app. + +### 30.3 Isolamento + +Um agente só roda bots quando o Botloft impõe o que a seção 7.5 impõe no Claude: nada de ler as outras crews nem os dados do Botloft. Cada agente usa o seu mecanismo, e o resultado do teste real entra aqui antes de o agente ser liberado. + +### 30.4 O que foi visto (2026-10-09) + +| Item | Resultado | Teste real | +|---|---|---| +| `agy` 1.3.1: modo de impressão com `stream-json` | `agy --input-format stream-json --output-format stream-json --model -p=` lê uma linha JSON por mensagem: `{"event":"user","message":{"role":"user","content":[{"type":"text","text":"..."}]}}`. Sem `event` ou sem `content` responde erro explícito. O `-p` precisa do `=` quando outras flags vêm depois | feito: um turno, `gemini-3.8-flash-low` | +| `agy`: eventos de saída | `init` (`conversation_id`, `model`, `cwd`, `tools`, `permission_mode` = `request-review`), `step_update` (`step_type` `user_input` ou `agent_response`, `state` `ACTIVE` ou `DONE`, `text_delta`, `usage`) e `result` (`status` `SUCCESS` ou `ERROR`, `response`, `usage` com `input_tokens`, `output_tokens`, `thinking_tokens`, `cache_read_tokens`, `total_tokens`). O processo saiu com o fim do stdin | feito: um turno | +| `agy`: vários turnos num processo, ferramentas, pedido de aprovação, MCP por HTTP, `--conversation`, isolamento por `--sandbox` e `--add-dir` | **Não visto** | a fazer (A2) | +| `agy models` | Lista `gemini-3.8-flash-{high,medium,low}`, `gemini-3.7-...`, `gemini-3.1-pro-{high,low}` e `claude-opus-5-5-{low,medium,high}`, entre outros: o nível de esforço faz parte do id do modelo | feito | +| `codex` 0.148.0-alpha.9: `exec --json` | Um turno por processo: `thread.started`, `turn.started`, `item.completed` (`agent_message`), `turn.completed` com `usage` (`input_tokens`, `cached_input_tokens`, `output_tokens`, `reasoning_output_tokens`). Não serve para um bot que fica ligado | feito: um turno, `read-only` | +| `codex app-server` | Experimental, JSON-RPC. `app-server generate-json-schema` gera o esquema, que traz pedidos de aprovação (`CommandExecutionRequestApprovalParams`, `FileChangeRequestApprovalParams`, `PermissionsRequestApprovalParams`, `McpServerElicitationRequestParams`) e `DynamicToolCallParams` | esquema gerado e lido; conversa **não vista** (A3) | +| `codex`: pasta, sandbox e MCP | `-C`, `--add-dir`, `-s read-only\|workspace-write\|danger-full-access`, `codex mcp`, `-c chave=valor`, `AGENTS.md` | só o `--help` lido |