brito-cli 0.6.4 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -147,6 +147,7 @@ OK: 20 arquivos, 66 símbolos, 54 chamadas, 17 imports | +0 ~0 -0 =20 (28ms)
147
147
  |---|---|
148
148
  | `/help` | list commands |
149
149
  | `/model`, `/effort`, `/reasoning` | choose model, reasoning effort and thinking (session, global or once) |
150
+ | `/fallback` | shows the main model and the fallback (qwen), and whether the switch already happened |
150
151
  | `/modo` or `Shift+Tab` | switch permission mode: manual → auto-edits → plan → bypass ("everything allowed") |
151
152
  | `/skills` | list skills · `pending` · `approve <name>` · `reject <name>` · `consolidate` · `rm <name>` · `promote <name>` |
152
153
  | `/learn` | extract skills from the last turn now |
@@ -178,6 +179,47 @@ Brito has **no database drivers and connects to nothing on its own**. When it ne
178
179
  - Prefer read-only roles and non-production environments when you give an agent database access.
179
180
  - A proposed skill that contains a destructive command is highlighted at review time, so you decide whether it needs a guard (backup, copy, confirmation).
180
181
 
182
+ ## OpenAI-compatible endpoint (e.g. Qwen on OCI)
183
+
184
+ > Full step-by-step (install, key, profile, and how to give people access to the repository, in Portuguese): [docs/GUIA-QWEN.md](docs/GUIA-QWEN.md).
185
+
186
+ Besides Claude, Brito runs on any model served through the OpenAI **Chat Completions** API (vLLM, OCI Generative AI, LiteLLM, Ollama…) with tool calling. Add a profile to `~/.brito/config.json`:
187
+
188
+ ```json
189
+ {
190
+ "providers": {
191
+ "qwen": {
192
+ "type": "openai",
193
+ "label": "Qwen 27B (OCI)",
194
+ "baseURL": "https://inference.generativeai.<region>.oci.oraclecloud.com/openai/v1",
195
+ "model": "<model id; on OCI, the dedicated endpoint OCID>",
196
+ "apiKeyEnv": "BRITO_QWEN_API_KEY",
197
+ "contextWindow": 262144,
198
+ "vision": false,
199
+ "thinkingToggle": true
200
+ }
201
+ }
202
+ }
203
+ ```
204
+
205
+ The key is **not** stored in the config: put it in the environment variable named by `apiKeyEnv`. Then:
206
+
207
+ ```bash
208
+ brito --model qwen # this session
209
+ /model qwen --global # inside the REPL: make it the default
210
+ ```
211
+
212
+ With a profile selected, **everything** goes to the endpoint: main agent, subagents (`explore` stops using Haiku), context summaries and skill learning; Brito starts without `ANTHROPIC_API_KEY` and without the `claude` CLI. `/reasoning none` turns the model's reasoning off (much faster answers on Qwen). `thinkingToggle: true` only for servers that accept `chat_template_kwargs` (vLLM/Qwen); without it `/reasoning none` has no effect, but no unknown parameter is sent. Images are only sent with `"vision": true`. Deliberate exception: `BRITO_EXPLORE_MODEL` still decides the `explore` subagent; a `BRITO_LEARN_MODEL` pointing at Claude is ignored while a profile is active. A broken profile in the config never falls back to Claude: Brito warns and stops. `/effort` stays exclusive to `claude` CLI mode, and a profile does not run inside the `claude` CLI (`/model` tells you to reopen with `--model`).
213
+
214
+ ### Fallback to Qwen
215
+
216
+ With Claude as the main model (API or `claude` CLI), the `qwen` profile is the automatic fallback — always that one, no new config key. If Claude fails because of a **subscription limit, rate limit, credit, authentication, overload or network** error, Brito warns and redoes the request on Qwen with the conversation so far (tools that already ran stay in the history, so they are not repeated). In `claude` CLI mode the whole engine switches to Brito's own loop.
217
+
218
+ - The switch happens once per session and never reverts on its own: `/model <claude>` goes back to Claude (and the fallback can trigger again later).
219
+ - It does not switch on Ctrl+C or on content errors (invalid request, `max_tokens`, broken tool).
220
+ - Without `ANTHROPIC_API_KEY` and without the `claude` CLI, Brito opens straight on Qwen, with a warning. If the fallback is not ready, the usual error includes why (e.g. `BRITO_QWEN_API_KEY` is missing). An invalid `qwen` profile is still an error, never a silent fallback.
221
+ - `/fallback` shows the state. In the desktop app, send `/fallback` as a message; the status bar shows Qwen while the fallback is active.
222
+
181
223
  ## Configuration
182
224
 
183
225
  `BRITO_MODEL`, `BRITO_EFFORT`, `BRITO_EXPLORE_MODEL`, `BRITO_LEARN_MODEL`, `BRITO_HOME` (where global skills and history live), `BRITO_CONFIG`, `BRITO_COLOR`, `BRITO_BANNER` (`compact` or `full`; default: full screen on windows of 95+ columns and 28+ rows), `BRITO_INDEX_IGNORED`, `BRITO_SKILL_MIN_TOOLS` (how long a turn must be to trigger reflection when nothing else does), `BRITO_SKILLS` (`auto` default, `ask`, `off`), `BRITO_SHELL` (shell used by the `bash` tool; on Windows defaults to Git Bash when installed).
package/README.pt-BR.md CHANGED
@@ -143,6 +143,7 @@ OK: 20 arquivos, 66 símbolos, 54 chamadas, 17 imports | +0 ~0 -0 =20 (28ms)
143
143
  |---|---|
144
144
  | `/help` | lista os comandos |
145
145
  | `/model`, `/effort`, `/reasoning` | escolhem modelo, esforço de raciocínio e thinking (sessão, global ou uma vez) |
146
+ | `/fallback` | mostra o modelo principal e o fallback (qwen), e se a troca já aconteceu |
146
147
  | `/modo` ou `Shift+Tab` | troca o modo de permissão: manual → auto-edições → plano → tudo liberado |
147
148
  | `/skills` | lista skills · `pending` · `approve <nome>` · `reject <nome>` · `consolidate` · `rm <nome>` · `promote <nome>` |
148
149
  | `/learn` | extrai skills do último turno agora |
@@ -174,6 +175,54 @@ O Brito **não tem drivers de banco e não se conecta a nada sozinho**. Quando p
174
175
  - Prefira papéis somente leitura e ambientes que não sejam de produção ao dar acesso a banco para um agente.
175
176
  - Uma skill proposta que contém comando destrutivo é destacada na revisão, para você decidir se precisa de uma guarda (backup, cópia, confirmação).
176
177
 
178
+ ## Endpoint compatível com OpenAI (ex.: Qwen no OCI)
179
+
180
+ > Passo a passo completo (instalação, chave, perfil e como dar acesso ao repositório): [docs/GUIA-QWEN.md](docs/GUIA-QWEN.md).
181
+
182
+ Além do Claude, o Brito roda com qualquer modelo servido pela API **Chat Completions** da OpenAI (vLLM, OCI Generative AI, LiteLLM, Ollama…) com chamada de ferramentas. Cadastre um perfil em `~/.brito/config.json`:
183
+
184
+ ```json
185
+ {
186
+ "providers": {
187
+ "qwen": {
188
+ "type": "openai",
189
+ "label": "Qwen 27B (OCI)",
190
+ "baseURL": "https://inference.generativeai.<região>.oci.oraclecloud.com/openai/v1",
191
+ "model": "<id do modelo; no OCI, o OCID do endpoint dedicado>",
192
+ "apiKeyEnv": "BRITO_QWEN_API_KEY",
193
+ "contextWindow": 262144,
194
+ "vision": false,
195
+ "thinkingToggle": true
196
+ }
197
+ }
198
+ }
199
+ ```
200
+
201
+ A chave **não** fica no config: ponha na variável de ambiente indicada em `apiKeyEnv`. Depois:
202
+
203
+ ```bash
204
+ brito --model qwen # esta sessão
205
+ /model qwen --global # dentro do REPL: vira o padrão
206
+ ```
207
+
208
+ Com um perfil escolhido, **tudo** vai para o endpoint: agente principal, subagentes (o `explore` deixa de usar o Haiku), resumo de contexto e aprendizado de skills; o Brito abre sem `ANTHROPIC_API_KEY` e sem o `claude` CLI. `/reasoning none` desliga o raciocínio do modelo (respostas bem mais rápidas no Qwen). `thinkingToggle: true` só para servidores que aceitam `chat_template_kwargs` (vLLM/Qwen); sem ele o `/reasoning none` não tem efeito, mas nenhum parâmetro estranho é enviado. Imagens só são enviadas com `"vision": true`. Exceção consciente: `BRITO_EXPLORE_MODEL` continua mandando no subagente `explore`; já um `BRITO_LEARN_MODEL` que aponte para um Claude é ignorado enquanto um perfil estiver ativo. Perfil com erro no config não cai no Claude: o Brito avisa e para. `/effort` continua exclusivo do modo `claude` CLI, e um perfil não roda dentro do `claude` CLI (o `/model` avisa para reabrir com `--model`).
209
+
210
+ ### Fallback para o Qwen
211
+
212
+ Com o Claude como modelo principal (API ou `claude` CLI), o perfil `qwen` é o fallback automático — sempre ele, sem chave nova no config. Se o Claude falha por **limite da assinatura, rate limit, crédito, autenticação, sobrecarga ou rede**, o Brito avisa e refaz o pedido no Qwen com a conversa até ali (as tools que já rodaram ficam no histórico, para não serem repetidas). No modo `claude` CLI o motor inteiro troca para o loop próprio do Brito.
213
+
214
+ - A troca acontece uma vez por sessão e não volta sozinha: `/model <claude>` volta ao Claude (e o fallback pode disparar de novo depois).
215
+ - Não troca por Ctrl+C nem por erro de conteúdo (requisição inválida, `max_tokens`, tool quebrada).
216
+ - Sem `ANTHROPIC_API_KEY` e sem o `claude` CLI, o Brito já abre no Qwen, com aviso. Sem o fallback pronto, o erro de sempre inclui o motivo (ex.: falta `BRITO_QWEN_API_KEY`). Perfil `qwen` inválido continua sendo erro, nunca fallback silencioso.
217
+ - `/fallback` mostra o estado. No app de desktop, envie `/fallback` como mensagem; a barra de status mostra o Qwen quando o fallback está ativo.
218
+
219
+ ```
220
+ /fallback
221
+ principal claude-opus-5-5 (claude CLI · assinatura)
222
+ fallback Qwen 27B (OCI) qwen · ~/.brito/config.json ✓ pronto
223
+ estado inativo — troca automática se o Claude falhar por limite, crédito, auth ou sobrecarga
224
+ ```
225
+
177
226
  ## Configuração
178
227
 
179
228
  `BRITO_MODEL`, `BRITO_EFFORT`, `BRITO_EXPLORE_MODEL`, `BRITO_LEARN_MODEL`, `BRITO_HOME` (onde ficam skills globais e histórico), `BRITO_CONFIG`, `BRITO_COLOR`, `BRITO_BANNER` (`compact` ou `full`; padrão: tela cheia em janelas de 95+ colunas e 28+ linhas), `BRITO_INDEX_IGNORED`, `BRITO_SKILL_MIN_TOOLS` (tamanho mínimo do turno para disparar a reflexão quando nada mais dispara), `BRITO_SKILLS` (`auto` padrão, `ask`, `off`), `BRITO_SHELL` (shell da tool `bash`; no Windows usa o Git Bash se instalado).
@@ -8,7 +8,7 @@ const BUILTIN = [
8
8
  name: "explore",
9
9
  description: "Somente leitura e rápido. Localiza código, mapeia fluxos e responde 'onde/como/quem chama' usando o grafo. Não edita nada.",
10
10
  tools: READ_TOOLS,
11
- model: process.env.BRITO_EXPLORE_MODEL ?? "claude-haiku-4-5-20251001",
11
+ // Sem modelo fixo: subagentModel (engine.ts) escolhe Haiku no Claude, o modelo principal num endpoint, ou BRITO_EXPLORE_MODEL.
12
12
  prompt: "Você é um agente de exploração de código. Descubra o que foi pedido com o mínimo de leitura: comece pelo grafo (graph_search/graph_context/graph_callers/graph_callees), leia só os trechos apontados. Entregue achados com caminhos `arquivo:linha`, sem despejar código longo.",
13
13
  source: "builtin",
14
14
  },
@@ -7,9 +7,11 @@ import { skillsIndex } from "../skills/store.js";
7
7
  import { MCP_NAME, MCP_PREFIX, APPROVE_TOOL } from "../mcp/server.js";
8
8
  import { ui as terminalUi } from "../ui/ui.js";
9
9
  import { MODE_ENV, perms } from "../permissions.js";
10
- import { wireEffort } from "../model.js";
10
+ import { DEFAULT_MODEL, wireEffort } from "../model.js";
11
+ import { subagentModel } from "../engine.js";
11
12
  import { claudeCommand } from "../util/platform.js";
12
13
  import { fmtBytes, findImages, splitOversize } from "../util/images.js";
14
+ import { classifyClaudeCliFailure, collectClaudeFailure, shortError } from "../provider/fallback.js";
13
15
  // Backend alternativo: delega ao `claude` oficial, que autentica com o /login do usuário.
14
16
  // O brito nunca lê nem envia o token da assinatura. Grafo, memória e skills chegam ao claude via MCP.
15
17
  export function hasClaudeCli() {
@@ -21,6 +23,89 @@ const resultText = (c) => (typeof c === "string" ? c : Array.isArray(c) ? c.map(
21
23
  /** Nome das tools do Brito → nome que o claude enxerga. */
22
24
  const CLAUDE_TOOL = { read_file: "Read", glob: "Glob", grep: "Grep", write_file: "Write", edit_file: "Edit", bash: "Bash" };
23
25
  const claudeToolName = (n) => CLAUDE_TOOL[n] ?? MCP_PREFIX + n;
26
+ /** Nome nativo do claude → tool do Brito (inverso de CLAUDE_TOOL); os MCP já chegam sem o prefixo. */
27
+ const BRITO_TOOL = Object.fromEntries(Object.entries(CLAUDE_TOOL).map(([b, c]) => [c, b]));
28
+ export const ORPHAN_RESULT = "o modelo anterior caiu antes do resultado";
29
+ const blocks = (c) => (typeof c === "string" ? (c ? [{ type: "text", text: c }] : []) : [...c]);
30
+ /** tool_use com o nome e os parâmetros do Brito (Read/Write/Edit usam `file_path`; o Brito usa `path`). */
31
+ function britoToolUse(b) {
32
+ const name = BRITO_TOOL[b.name] ?? b.name;
33
+ let input = b.input;
34
+ if (name !== b.name && input && typeof input === "object" && "file_path" in input && !("path" in input)) {
35
+ const { file_path, ...rest } = input;
36
+ input = { path: file_path, ...rest };
37
+ }
38
+ return { ...b, name, input };
39
+ }
40
+ /**
41
+ * Histórico do claude CLI (todos os TurnRecord da sessão) → `Msg[]` válido para o loop próprio, na troca para o qwen.
42
+ * O stream-json manda um evento por bloco, então mensagens seguidas do mesmo papel viram uma; `tool_use` sem
43
+ * `tool_result` (o turno que caiu) ganha um resultado de erro. Não altera os TurnRecord (a reflexão ainda os usa).
44
+ */
45
+ export function turnsToMessages(turns) {
46
+ const out = [];
47
+ for (const m of turns.flatMap((t) => t.messages)) {
48
+ const content = blocks(m.content).map((b) => (b.type === "tool_use" ? britoToolUse(b) : b));
49
+ if (!content.length)
50
+ continue;
51
+ const last = out.at(-1);
52
+ if (last?.role === m.role)
53
+ last.content.push(...content);
54
+ else
55
+ out.push({ role: m.role, content });
56
+ }
57
+ for (let i = 0; i < out.length; i++) {
58
+ if (out[i].role !== "assistant")
59
+ continue;
60
+ const next = out[i + 1]?.role === "user" ? out[i + 1] : null;
61
+ const answered = new Set(next?.content.flatMap((b) => (b.type === "tool_result" ? [b.tool_use_id] : [])));
62
+ const missing = out[i].content.flatMap((b) => (b.type === "tool_use" && !answered.has(b.id)
63
+ ? [{ type: "tool_result", tool_use_id: b.id, content: ORPHAN_RESULT, is_error: true }] : []));
64
+ if (!missing.length)
65
+ continue;
66
+ if (next)
67
+ next.content.unshift(...missing);
68
+ else
69
+ out.splice(i + 1, 0, { role: "user", content: missing });
70
+ }
71
+ return out;
72
+ }
73
+ /**
74
+ * Histórico que o qwen herda na troca: todos os turnos, menos o pedido do turno que caiu quando ele não chegou a
75
+ * produzir nada (o pedido volta logo em seguida, com a nota de `retryPrompt`, e ficaria duplicado).
76
+ */
77
+ export function historyForFallback(turns) {
78
+ const failed = turns.at(-1);
79
+ return turnsToMessages(failed && failed.messages.length <= 1 ? turns.slice(0, -1) : turns);
80
+ }
81
+ const HANDOFF_BUDGET = 60_000;
82
+ const clip = (s, n) => (s.length > n ? s.slice(0, n) + `… [+${s.length - n} caracteres]` : s);
83
+ /**
84
+ * Turnos feitos no modo API → texto que abre o próximo prompt do claude CLI na volta. O claude retoma a própria sessão
85
+ * (--resume) e não viu nada disso; com o texto no prompt, ele fica gravado na sessão dele e vale para os turnos seguintes.
86
+ * Passando de `budget`, os turnos mais antigos saem primeiro.
87
+ */
88
+ export function handoffNote(turns, label, budget = HANDOFF_BUDGET) {
89
+ const rendered = turns.map((t) => t.messages.flatMap((m) => blocks(m.content).flatMap((b) => {
90
+ if (b.type === "text" && b.text)
91
+ return [`${m.role === "user" ? "USUÁRIO" : "AGENTE"}: ${clip(b.text, 4_000)}`];
92
+ if (b.type === "tool_use")
93
+ return [`→ ${b.name}(${clip(JSON.stringify(b.input), 600)})`];
94
+ if (b.type === "tool_result")
95
+ return [`← ${b.is_error ? "[ERRO] " : ""}${clip(resultText(b.content), b.is_error ? 1_500 : 1_000)}`];
96
+ return [];
97
+ })).join("\n")).filter(Boolean);
98
+ if (!rendered.length)
99
+ return "";
100
+ let total = rendered.reduce((n, t) => n + t.length + 2, 0), dropped = 0;
101
+ while (rendered.length > 1 && total > budget) {
102
+ total -= rendered.shift().length + 2;
103
+ dropped++;
104
+ }
105
+ return `[Contexto: enquanto você esteve fora, a conversa seguiu no ${label} (modo API do Brito). É isto que aconteceu lá, em ordem; ` +
106
+ `as edições e comandos já estão no disco. Continue a partir daqui.${dropped ? ` (${dropped} turno(s) mais antigo(s) omitido(s))` : ""}]\n\n` +
107
+ `${rendered.join("\n\n")}\n\n[Fim do contexto. Pedido atual:]\n\n`;
108
+ }
24
109
  /** Agentes do Brito viram subagentes do claude (--agents), no lugar do `delegate`. */
25
110
  function claudeAgents() {
26
111
  const out = {};
@@ -29,7 +114,7 @@ function claudeAgents() {
29
114
  description: a.description,
30
115
  prompt: a.prompt,
31
116
  ...(a.tools === "all" ? {} : { tools: a.tools.map(claudeToolName) }),
32
- ...(a.model?.includes("haiku") ? { model: "haiku" } : {}),
117
+ ...(subagentModel(a, DEFAULT_MODEL, process.env).includes("haiku") ? { model: "haiku" } : {}),
33
118
  };
34
119
  }
35
120
  return out;
@@ -39,7 +124,8 @@ function claudeAgents() {
39
124
  * Só a thread principal entra: mensagens de subagentes (`parent_tool_use_id`) têm contexto próprio.
40
125
  */
41
126
  export function recordStreamEvent(turn, ev, loadedSkills) {
42
- if (ev?.parent_tool_use_id)
127
+ // A mensagem sintética de erro ("5-hour limit reached"…) não é fala do modelo: o qwen a herdaria como resposta.
128
+ if (ev?.parent_tool_use_id || ev?.is_api_error_message)
43
129
  return;
44
130
  if (ev?.type === "assistant") {
45
131
  const rec = [];
@@ -92,6 +178,10 @@ export class ClaudeSession {
92
178
  /** transcript do último turno (thread principal), montado do stream-json para a reflexão de skills */
93
179
  lastTurn = null;
94
180
  recentTurns = [];
181
+ /** todos os turnos da sessão, sem corte: viram o histórico do qwen se o claude cair (`turnsToMessages`) */
182
+ allTurns = [];
183
+ /** por que o último turno falhou (null = deu certo ou foi interrompido); o fallback para o qwen decide por ele */
184
+ lastFailure = null;
95
185
  /** skills carregadas via skill_load (que roda no servidor MCP, outro processo) */
96
186
  loadedSkills = new Set();
97
187
  /** último turno em andamento, para o Ctrl+C */
@@ -102,7 +192,19 @@ export class ClaudeSession {
102
192
  get sessionId() { return this.id; }
103
193
  /** Retoma uma conversa do claude (--resume) em vez de começar outra. */
104
194
  resume(id) { this.id = id; this.started = true; }
105
- reset() { this.id = randomUUID(); this.started = false; this.contextTokens = 0; this.recentTurns = []; }
195
+ reset() { this.id = randomUUID(); this.started = false; this.contextTokens = 0; this.recentTurns = []; this.allTurns = []; this.pendingHandoff = ""; }
196
+ /** contexto do modo API ainda não entregue ao claude: abre o próximo prompt (`handoffNote`) */
197
+ pendingHandoff = "";
198
+ /**
199
+ * Volta do modo API: os turnos feitos lá chegam ao claude no próximo prompt e entram em `allTurns`, para que uma
200
+ * nova queda para o qwen herde também o que foi feito nele.
201
+ */
202
+ handoff(turns, label) {
203
+ if (!turns.length)
204
+ return;
205
+ this.pendingHandoff += handoffNote(turns, label);
206
+ this.allTurns.push(...turns);
207
+ }
106
208
  abort() { if (!this.child)
107
209
  return; this.aborted = true; this.child.kill("SIGINT"); }
108
210
  /** No Windows `kill("SIGINT")` termina o processo sem sinal nem código 130: lembramos que fomos nós que interrompemos. */
@@ -148,21 +250,26 @@ export class ClaudeSession {
148
250
  if (imgs.length)
149
251
  ui.info(`📎 ${imgs.map((i) => `${i.name} (${fmtBytes(i.bytes)})`).join(", ")} anexado${imgs.length > 1 ? "s" : ""}`);
150
252
  // O claude vê a imagem pela tool Read: o prompt diz quais arquivos são imagens anexadas pelo usuário.
151
- const prompt = imgs.length ? `${text}\n\n[O usuário anexou ${imgs.length > 1 ? "estes arquivos" : "este arquivo"}; abra com a tool Read para vê-${imgs.length > 1 ? "las" : "la"}:\n${imgs.map((i) => `- ${i.path}`).join("\n")}]` : text;
152
- const cmd = claudeCommand(this.args(prompt, [...new Set(imgs.map((i) => path.dirname(i.path)))]));
253
+ const prompt = this.pendingHandoff + (imgs.length ? `${text}\n\n[O usuário anexou ${imgs.length > 1 ? "estes arquivos" : "este arquivo"}; abra com a tool Read para vê-${imgs.length > 1 ? "las" : "la"}:\n${imgs.map((i) => `- ${i.path}`).join("\n")}]` : text);
254
+ const cmd = (this.o.command ?? claudeCommand)(this.args(prompt, [...new Set(imgs.map((i) => path.dirname(i.path)))]));
153
255
  const child = (this.child = spawn(cmd.command, cmd.args, {
154
256
  cwd: this.o.root, stdio: ["ignore", "pipe", "pipe"],
155
257
  // `/reasoning none`: o claude desliga o raciocínio com MAX_THINKING_TOKENS=0 (não há `--effort none`)
156
258
  env: { ...process.env, ...this.o.env, BRITO_DEPTH: String(Number(process.env.BRITO_DEPTH ?? 0) + 1), ...(wireEffort(this.effort).thinking ? {} : { MAX_THINKING_TOKENS: "0" }) },
157
259
  }));
158
260
  let buf = "", stderr = "", ok = true, interrupted = false;
261
+ const failure = { stderr: "", code: null };
262
+ this.lastFailure = null;
159
263
  const names = new Map();
160
264
  const turn = { messages: [{ role: "user", content: text }], toolCalls: 0, errors: 0, finished: false };
161
265
  this.lastTurn = turn;
162
266
  this.recentTurns = [...this.recentTurns, turn].slice(-3);
267
+ this.allTurns.push(turn);
163
268
  const handle = (ev) => {
164
269
  recordStreamEvent(turn, ev, this.loadedSkills);
165
- if (ev.type === "assistant") {
270
+ collectClaudeFailure(failure, ev);
271
+ // A mensagem sintética de erro sai pelo `result` (ui.error); aqui viraria fala do Brito e modelo "<synthetic>".
272
+ if (ev.type === "assistant" && !ev.is_api_error_message) {
166
273
  for (const b of ev.message?.content ?? []) {
167
274
  if (b.type === "thinking" && b.thinking && !ev.parent_tool_use_id)
168
275
  ui.thinking(b.thinking);
@@ -189,6 +296,10 @@ export class ClaudeSession {
189
296
  ui.toolEnd(b.tool_use_id, !!b.is_error, resultText(b.content));
190
297
  }
191
298
  else if (ev.type === "result") {
299
+ // Houve turno (mesmo com erro, ex.: limite da assinatura): o claude já gravou a sessão com este id, e o
300
+ // próximo turno precisa de --resume — repetir --session-id dá "Session ID … is already in use".
301
+ this.started = true;
302
+ this.pendingHandoff = ""; // o prompt com o contexto já está gravado na sessão do claude
192
303
  this.usage.input += (ev.usage?.input_tokens ?? 0) + (ev.usage?.cache_read_input_tokens ?? 0) + (ev.usage?.cache_creation_input_tokens ?? 0);
193
304
  this.usage.output += ev.usage?.output_tokens ?? 0;
194
305
  this.usage.cacheRead += ev.usage?.cache_read_input_tokens ?? 0;
@@ -217,7 +328,7 @@ export class ClaudeSession {
217
328
  }
218
329
  });
219
330
  child.stderr.on("data", (d) => { stderr += d.toString(); });
220
- child.on("error", (e) => { ok = false; ui.error(`não consegui executar o claude: ${e.message}`); });
331
+ child.on("error", (e) => { ok = false; failure.spawnError = e.message; ui.error(`não consegui executar o claude: ${e.message}`); });
221
332
  child.on("close", (code, signal) => {
222
333
  this.child = null;
223
334
  interrupted = signal === "SIGINT" || code === 130 || this.aborted;
@@ -227,9 +338,16 @@ export class ClaudeSession {
227
338
  if (stderr.trim())
228
339
  ui.error(stderr.trim().slice(0, 600));
229
340
  }
230
- else if (code === 0)
341
+ else if (code === 0) {
231
342
  this.started = true;
343
+ this.pendingHandoff = "";
344
+ }
232
345
  turn.finished = ok && !interrupted && code === 0;
346
+ if (!ok && !interrupted) {
347
+ Object.assign(failure, { stderr, code });
348
+ const why = failure.spawnError ?? (failure.result || stderr.trim() || `claude saiu com código ${code}`);
349
+ this.lastFailure = { kind: classifyClaudeCliFailure(failure), message: shortError(why) };
350
+ }
233
351
  if (interrupted)
234
352
  ui.warn("⏹ interrompido");
235
353
  ui.turnEnd(ok && !interrupted);
@@ -1,3 +1,4 @@
1
+ import { providerFor } from "../provider/index.js";
1
2
  /** Imagem em base64 tem megabytes, mas custa ~1,6 mil tokens: troca o conteúdo por um marcador antes de medir ou resumir. */
2
3
  const stubImages = (_k, v) => (v && typeof v === "object" && (v.type === "image" || v.type === "document") && v.source ? { type: v.type, note: "x".repeat(6400) } : v);
3
4
  export const estimateTokens = (msgs) => Math.ceil(JSON.stringify(msgs, stubImages).length / 4);
@@ -19,7 +20,7 @@ export function trimOldToolResults(msgs, keep = 8) {
19
20
  });
20
21
  }
21
22
  /** Fase 2: resume o meio da conversa via LLM, mantendo o início e os últimos turnos. */
22
- export async function summarizeMiddle(client, model, msgs, keepTail = 6) {
23
+ export async function summarizeMiddle(model, msgs, keepTail = 6) {
23
24
  if (msgs.length <= keepTail + 2)
24
25
  return msgs;
25
26
  let cut = msgs.length - keepTail;
@@ -31,9 +32,11 @@ export async function summarizeMiddle(client, model, msgs, keepTail = 6) {
31
32
  const head = msgs.slice(0, 1);
32
33
  const middle = msgs.slice(1, cut);
33
34
  const tail = msgs.slice(cut);
34
- const res = await client.messages.create({
35
+ // Raciocínio desligado: num endpoint com raciocínio ele comeria o orçamento do resumo.
36
+ const res = await providerFor(model).send({
35
37
  model,
36
- max_tokens: 2000,
38
+ maxTokens: 2000,
39
+ thinking: false,
37
40
  system: "Resuma a conversa de um agente de programação. Preserve: objetivo, decisões, arquivos/símbolos tocados, erros encontrados, estado atual e próximos passos. Seja denso.",
38
41
  messages: [{ role: "user", content: JSON.stringify(middle, (_k, v) => (v && typeof v === "object" && (v.type === "image" || v.type === "document") && v.source ? { type: v.type, note: "[anexo omitido]" } : v)).slice(0, 150_000) }],
39
42
  });
@@ -1,25 +1,30 @@
1
- import Anthropic from "@anthropic-ai/sdk";
2
1
  import { toApiTools, execTool, isConcurrent } from "../tools/registry.js";
3
2
  import { estimateTokens, summarizeMiddle, trimOldToolResults } from "./context.js";
4
3
  import { ui as terminalUi } from "../ui/ui.js";
5
- import { initialModel } from "../model.js";
4
+ import { initialEffort, initialModel } from "../model.js";
5
+ import { providerFor } from "../provider/index.js";
6
+ import { compactAtFor, resolveModel } from "../provider/resolve.js";
7
+ import { fallbackStatus } from "../engine.js";
8
+ import { activateFallback, classifyAnthropicError, fallbackWarning, shortError, triggersFallback } from "../provider/fallback.js";
6
9
  import { fmtBytes, findImages, imageBlock, splitOversize } from "../util/images.js";
7
10
  export const MODEL = initialModel();
8
- const COMPACT_AT = Number(process.env.BRITO_COMPACT_TOKENS ?? 120_000);
9
11
  export class Agent {
10
12
  system;
11
13
  ctx;
12
14
  opts;
13
- client = new Anthropic();
14
15
  messages = [];
15
16
  usage = { input: 0, output: 0, cacheRead: 0, steps: 0 };
16
17
  /** tamanho do contexto na última requisição (alimenta a barra de status) */
17
18
  contextTokens = 0;
18
19
  /** troca em runtime via `/model`; vale a partir da próxima requisição */
19
20
  model;
21
+ /** esforço de raciocínio (`/reasoning`); `none` desliga o raciocínio em endpoints que permitem */
22
+ effort = initialEffort();
20
23
  lastTurn = null;
21
24
  /** últimos turnos (o mais novo por último): o anterior dá contexto a correções do usuário */
22
25
  recentTurns = [];
26
+ /** turnos ainda não entregues ao claude CLI: na volta para ele, viram o contexto do próximo prompt (`handoff`) */
27
+ unsentTurns = [];
23
28
  maxSteps;
24
29
  log;
25
30
  ui;
@@ -58,6 +63,7 @@ export class Agent {
58
63
  const turn = { messages: [], toolCalls: 0, errors: 0, finished: false };
59
64
  this.lastTurn = turn;
60
65
  this.recentTurns = [...this.recentTurns, turn].slice(-3);
66
+ this.unsentTurns.push(turn);
61
67
  const push = (m) => { this.messages.push(m); turn.messages.push(m); };
62
68
  // Imagens arrastadas para o terminal chegam como caminhos no texto: anexa como blocos `image`. O registro do turno
63
69
  // (usado na reflexão) guarda só o texto, para não carregar base64.
@@ -65,6 +71,9 @@ export class Agent {
65
71
  for (const i of tooBig)
66
72
  this.log(`⚠ ${i.name} passa do limite da API e não foi anexado`);
67
73
  if (ok.length) {
74
+ const target = resolveModel(this.model);
75
+ if (target.kind === "openai" && !target.profile.vision)
76
+ this.log(`⚠ o modelo ${target.label} não aceita imagens; ${ok.length > 1 ? `${ok.length} anexos não foram enviados` : "o anexo não foi enviado"} (vision: true no perfil liga)`);
68
77
  this.log(`📎 ${ok.map((i) => `${i.name} (${fmtBytes(i.bytes)})`).join(", ")} anexado${ok.length > 1 ? "s" : ""}`);
69
78
  this.messages.push({ role: "user", content: [...ok.map(imageBlock), { type: "text", text: userText }] });
70
79
  turn.messages.push({ role: "user", content: userText });
@@ -76,29 +85,30 @@ export class Agent {
76
85
  for (let step = 0; step < this.maxSteps; step++) {
77
86
  if (signal?.aborted)
78
87
  throw new Error("abort");
79
- const stream = this.client.messages.stream({
80
- model: this.model,
81
- max_tokens: 8192,
82
- system: [{ type: "text", text: typeof this.system === "function" ? this.system() : this.system, cache_control: { type: "ephemeral" } }],
83
- tools: toApiTools(this.opts.tools),
84
- messages: this.messages,
85
- }, { signal });
86
- if (!this.opts.quiet)
87
- stream.on("text", (t) => this.ui.text(t));
88
- const msg = await stream.finalMessage();
89
- this.contextTokens = msg.usage.input_tokens + (msg.usage.cache_read_input_tokens ?? 0) + (msg.usage.cache_creation_input_tokens ?? 0);
90
- this.usage.input += msg.usage.input_tokens + (msg.usage.cache_read_input_tokens ?? 0) + (msg.usage.cache_creation_input_tokens ?? 0);
91
- this.usage.output += msg.usage.output_tokens;
92
- this.usage.cacheRead += msg.usage.cache_read_input_tokens ?? 0;
88
+ const msg = await this.callModel({ signal, onText: this.opts.quiet ? undefined : (t) => this.ui.text(t), onThinking: this.opts.quiet ? undefined : (t) => this.ui.thinking(t) });
89
+ this.contextTokens = msg.usage.input;
90
+ this.usage.input += msg.usage.input;
91
+ this.usage.output += msg.usage.output;
92
+ this.usage.cacheRead += msg.usage.cacheRead;
93
93
  this.usage.steps++;
94
- push({ role: "assistant", content: msg.content });
94
+ if (msg.stopReason === "max_tokens")
95
+ this.log("⚠ resposta cortada pelo limite de tokens; peça para continuar ou desligue o raciocínio (/reasoning none)");
96
+ // Mensagem vazia não entra no histórico: a API da Anthropic recusaria o próximo turno.
97
+ if (msg.content.length)
98
+ push({ role: "assistant", content: msg.content });
95
99
  const text = msg.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
96
100
  if (text)
97
101
  finalText = text;
98
102
  if (!this.opts.quiet)
99
103
  this.ui.textEnd();
100
104
  const uses = msg.content.filter((b) => b.type === "tool_use");
101
- if (msg.stop_reason !== "tool_use" || uses.length === 0) {
105
+ if (uses.length && msg.stopReason !== "tool_use") {
106
+ // Chamada de tool cortada no meio: fecha cada uma com erro para o histórico continuar válido.
107
+ push({ role: "user", content: uses.map((u) => ({ type: "tool_result", tool_use_id: u.id, content: "Chamada cortada antes de terminar (limite de tokens); não foi executada.", is_error: true })) });
108
+ turn.finished = true;
109
+ return finalText;
110
+ }
111
+ if (msg.stopReason !== "tool_use" || uses.length === 0) {
102
112
  turn.finished = true;
103
113
  return finalText;
104
114
  }
@@ -126,14 +136,50 @@ export class Agent {
126
136
  this.ui.warn("Limite de passos atingido.");
127
137
  return finalText || "(limite de passos atingido sem resposta final)";
128
138
  }
139
+ /**
140
+ * Um passo do loop principal. Se o Claude falha por conta/capacidade e o qwen está pronto, troca o modelo da sessão
141
+ * (uma vez só) e repete o mesmo passo: o histórico está intacto, porque a falha veio antes de a resposta entrar nele.
142
+ */
143
+ async callModel(hooks) {
144
+ // Num endpoint com raciocínio, pensar e responder dividem o mesmo orçamento: dobra para não cortar a resposta.
145
+ const target = resolveModel(this.model);
146
+ const thinking = this.effort !== "none";
147
+ try {
148
+ return await providerFor(this.model).send({
149
+ model: this.model,
150
+ maxTokens: target.kind === "openai" && thinking ? 16_384 : 8192,
151
+ system: typeof this.system === "function" ? this.system() : this.system,
152
+ tools: toApiTools(this.opts.tools),
153
+ messages: this.messages,
154
+ thinking,
155
+ }, hooks);
156
+ }
157
+ catch (e) {
158
+ const fb = this.opts.fallback;
159
+ if (!fb || fb.active || hooks.signal?.aborted || target.kind !== "anthropic")
160
+ throw e;
161
+ const kind = classifyAnthropicError(e);
162
+ if (!triggersFallback(kind))
163
+ throw e;
164
+ const status = fallbackStatus(this.model, process.env);
165
+ if (!status.ready)
166
+ throw e;
167
+ const why = shortError(e);
168
+ this.ui.warn(fallbackWarning(kind, why, status.label));
169
+ activateFallback(fb, `${kind} (${why})`);
170
+ this.model = status.name;
171
+ await this.compactIfNeeded();
172
+ return this.callModel(hooks);
173
+ }
174
+ }
129
175
  async compactIfNeeded() {
130
- if (estimateTokens(this.messages) < COMPACT_AT)
176
+ if (estimateTokens(this.messages) < compactAtFor(this.model))
131
177
  return;
132
178
  this.messages = trimOldToolResults(this.messages);
133
- if (estimateTokens(this.messages) < COMPACT_AT)
179
+ if (estimateTokens(this.messages) < compactAtFor(this.model))
134
180
  return;
135
181
  this.log("compactando contexto…");
136
- this.messages = await summarizeMiddle(this.client, this.model, this.messages);
182
+ this.messages = await summarizeMiddle(this.model, this.messages);
137
183
  this.contextTokens = estimateTokens(this.messages); // até a próxima chamada real, a barra não fica com o valor antigo
138
184
  }
139
185
  }