brito-cli 0.6.4 → 0.7.0
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 +42 -0
- package/README.pt-BR.md +49 -0
- package/dist/agent/agents.js +1 -1
- package/dist/agent/claude-cli.js +127 -9
- package/dist/agent/context.js +6 -3
- package/dist/agent/loop.js +69 -23
- package/dist/cli.js +312 -111
- package/dist/desktop/serve.js +133 -32
- package/dist/engine.js +79 -0
- package/dist/graph/store.js +15 -0
- package/dist/model.js +48 -2
- package/dist/provider/anthropic.js +31 -0
- package/dist/provider/fallback.js +147 -0
- package/dist/provider/index.js +27 -0
- package/dist/provider/openai-compat.js +151 -0
- package/dist/provider/openai-wire.js +89 -0
- package/dist/provider/resolve.js +27 -0
- package/dist/provider/sse.js +33 -0
- package/dist/provider/types.js +1 -0
- package/dist/repl.js +50 -0
- package/dist/skills/learn.js +3 -2
- package/dist/tools/delegate.js +3 -1
- package/package.json +1 -1
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).
|
package/dist/agent/agents.js
CHANGED
|
@@ -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
|
-
|
|
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
|
},
|
package/dist/agent/claude-cli.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/agent/context.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/agent/loop.js
CHANGED
|
@@ -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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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.
|
|
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) <
|
|
176
|
+
if (estimateTokens(this.messages) < compactAtFor(this.model))
|
|
131
177
|
return;
|
|
132
178
|
this.messages = trimOldToolResults(this.messages);
|
|
133
|
-
if (estimateTokens(this.messages) <
|
|
179
|
+
if (estimateTokens(this.messages) < compactAtFor(this.model))
|
|
134
180
|
return;
|
|
135
181
|
this.log("compactando contexto…");
|
|
136
|
-
this.messages = await summarizeMiddle(this.
|
|
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
|
}
|