@danieltmn/tamnora 0.11.2 → 0.12.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/CHANGELOG.md +31 -0
- package/README.md +7 -1
- package/harness/packages/cli/src/commands/repl.js +18 -5
- package/harness/packages/cli/src/commands/run.js +11 -2
- package/harness/packages/cli/src/commands/tui.js +13 -3
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +22 -0
- package/harness/packages/core/src/agent/compact.js +38 -0
- package/harness/packages/core/src/agent/context.js +13 -8
- package/harness/packages/core/src/agent/runAgent.js +15 -2
- package/harness/packages/core/src/agent/scope.js +15 -0
- package/harness/packages/core/src/config/index.js +4 -0
- package/harness/packages/core/src/index.js +10 -1
- package/harness/packages/core/src/providers/cache.js +29 -0
- package/harness/packages/core/src/providers/ollama.js +3 -2
- package/harness/packages/core/src/providers/openaiCompatible.js +9 -2
- package/harness/packages/core/src/providers/openrouter.js +6 -1
- package/harness/packages/core/src/providers/presets.js +7 -4
- package/harness/packages/core/src/providers/stream.js +45 -4
- package/harness/packages/core/src/skills/index.js +21 -0
- package/harness/packages/core/src/tools/executors.js +21 -13
- package/harness/packages/core/src/tools/index.js +2 -2
- package/harness/packages/core/src/tools/registry.js +3 -2
- package/harness/packages/core/test/agent.test.js +38 -0
- package/harness/packages/core/test/compact.test.js +33 -0
- package/harness/packages/core/test/context.test.js +14 -0
- package/harness/packages/core/test/providers.test.js +66 -0
- package/harness/packages/core/test/skills.test.js +10 -1
- package/harness/packages/core/test/stream.test.js +20 -0
- package/harness/packages/core/test/tools.test.js +18 -0
- package/harness/packages/server/public/app.js +93 -38
- package/harness/packages/server/public/index.html +8 -2
- package/harness/packages/server/public/markdown.js +57 -3
- package/harness/packages/server/public/styles.css +117 -13
- package/harness/packages/server/src/lib/agentRun.js +7 -7
- package/harness/packages/server/src/lib/agentTools.js +18 -3
- package/harness/packages/server/src/lib/approvals.js +7 -0
- package/harness/packages/server/src/lib/channels/telegram.js +9 -1
- package/harness/packages/server/src/lib/compact.js +77 -0
- package/harness/packages/server/src/lib/prompt.js +4 -1
- package/harness/packages/server/src/lib/skills.js +12 -1
- package/harness/packages/server/src/lib/tasks.js +2 -1
- package/harness/packages/server/src/routes/chat.js +21 -2
- package/harness/packages/server/src/routes/providers.js +4 -1
- package/harness/packages/server/src/routes/sessions.js +4 -50
- package/harness/packages/server/src/server.js +8 -2
- package/harness/packages/server/test/markdown.test.js +19 -2
- package/harness/packages/server/test/scope.test.js +68 -0
- package/harness/packages/server/test/server.test.js +130 -49
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
|
|
|
5
5
|
El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
|
|
6
6
|
y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
|
|
7
7
|
|
|
8
|
+
## [0.12.0] - 2026-10-10
|
|
9
|
+
|
|
10
|
+
### Agregado
|
|
11
|
+
|
|
12
|
+
- **Fase 1 del roadmap del server (contexto y costo)**:
|
|
13
|
+
- **Skills on-demand**: se inyecta un índice (`name` + `when`) y el modelo carga
|
|
14
|
+
el cuerpo con la tool `skill({ name })`. Config `skills.mode: lazy|eager`
|
|
15
|
+
(lazy por defecto). Ahorra tokens en cada turno.
|
|
16
|
+
- **`max_tokens` por ronda** (`maxOutputTokens`; 0 = default del proveedor), en
|
|
17
|
+
providers, `/api/settings` y CLI.
|
|
18
|
+
- **Prompt caching opt-in** (`promptCaching`): `cache_control` para Anthropic
|
|
19
|
+
(OpenRouter/Anthropic) y `prompt_cache_key` para OpenAI.
|
|
20
|
+
- **Auto-compactación** (`compact: { enabled, threshold: 0.75, keepLast: 8 }`):
|
|
21
|
+
resume lo viejo y lo reemplaza por `meta.summary` + `meta.summaryCursor` sin
|
|
22
|
+
borrar el historial. Evento `compacted`.
|
|
23
|
+
- **Seam de ciclo de vida** en `runAgent` (`run.start/end`, `tool.before/after`),
|
|
24
|
+
base para checkpoints y hooks.
|
|
25
|
+
|
|
26
|
+
### Arreglado
|
|
27
|
+
|
|
28
|
+
- `trimContext` ya no descarta el objetivo/resumen de la sesión al recortar
|
|
29
|
+
(protege los mensajes system iniciales).
|
|
30
|
+
- `effectiveStrictScope` no se exponía en `ctx`, lo que rompía `GET /api/settings`.
|
|
31
|
+
|
|
32
|
+
## [0.11.3] - 2026-10-09
|
|
33
|
+
|
|
34
|
+
### Cambios
|
|
35
|
+
|
|
36
|
+
- feat(consola): sidebar, estructura y vista previa de codigo
|
|
37
|
+
- docs(website): iconos y logo en la landing (favicon + header/footer)
|
|
38
|
+
|
|
8
39
|
## [0.11.2] - 2026-10-09
|
|
9
40
|
|
|
10
41
|
### Arreglado
|
package/README.md
CHANGED
|
@@ -52,6 +52,8 @@ Atajos: `enter` enviar · `↑/↓` historial · `←/→` cursor · `home/end`
|
|
|
52
52
|
`/limpiar`. El pegado de bloques multilinea entra como un solo texto y el header
|
|
53
53
|
muestra el contexto usado (`ctx <tokens>`). Las tools `write`/`edit` muestran un
|
|
54
54
|
**diff lado a lado** (viejo en rojo, nuevo en verde, con numeros de linea).
|
|
55
|
+
`/estricto` (o `--strict`) prende el modo **solo lo pedido**: el agente hace
|
|
56
|
+
exactamente lo que pedis, sin refactors ni cambios fuera del pedido.
|
|
55
57
|
|
|
56
58
|
### Server como servicio
|
|
57
59
|
|
|
@@ -106,7 +108,11 @@ menu "mas" (los tres puntitos). En el chat:
|
|
|
106
108
|
menu `⋮`; el indicador de "pensando/trabajando" vive en el pie (`#sb-activity`).
|
|
107
109
|
- **Runs durables**: cerrar el celular o cortar la red **no cancela** el turno; al
|
|
108
110
|
volver, la consola **reconecta** y reconstruye el progreso. Para cortarlo se usa
|
|
109
|
-
el boton de detener (o `POST /api/sessions/:id/stop`)
|
|
111
|
+
el boton de detener (o `POST /api/sessions/:id/stop`), que ademas corta el stream
|
|
112
|
+
del modelo y mata las tools de larga duracion (p. ej. `bash`) en curso.
|
|
113
|
+
- **Solo lo pedido (estricto)**: el toggle de la pestaña Agente (o `PUT
|
|
114
|
+
/api/settings` con `strictScope`) hace que el agente se limite a lo solicitado,
|
|
115
|
+
sin mejoras ni cambios extra; se combina con Plan o Build.
|
|
110
116
|
- **Audio**: un unico reproductor encola la narracion, el tool `speak`, la
|
|
111
117
|
respuesta final y los consejos del asesor, y reproduce de a uno con pausa y
|
|
112
118
|
detener (Ajustes -> Voz).
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
Store,
|
|
7
7
|
createTools,
|
|
8
8
|
buildMcpRegistry,
|
|
9
|
+
scopeDirective,
|
|
9
10
|
} from '../lib/core.js';
|
|
10
11
|
import { parse, databasePath, num } from '../lib/util.js';
|
|
11
12
|
import { buildSystemPrompt, runTurn, createPrinter, resolveCliProvider } from '../lib/runtime.js';
|
|
@@ -18,6 +19,8 @@ const OPTIONS = {
|
|
|
18
19
|
'max-steps': { type: 'string' },
|
|
19
20
|
tools: { type: 'string' },
|
|
20
21
|
'no-tools': { type: 'boolean' },
|
|
22
|
+
strict: { type: 'boolean' },
|
|
23
|
+
'no-strict': { type: 'boolean' },
|
|
21
24
|
yes: { type: 'boolean', short: 'y' },
|
|
22
25
|
};
|
|
23
26
|
|
|
@@ -26,9 +29,13 @@ export async function replCommand(argv) {
|
|
|
26
29
|
const cwd = values.dir ? path.resolve(values.dir) : process.cwd();
|
|
27
30
|
const config = await loadConfig(cwd);
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
const instructions = await loadInstructions(cwd, config.instructions);
|
|
33
|
+
let systemExtra = '';
|
|
30
34
|
const maxRounds = num(values['max-steps'], config.maxRounds);
|
|
31
35
|
const store = new Store(databasePath(cwd));
|
|
36
|
+
let strict = values.strict ? true : values['no-strict'] ? false : store.getSetting('strictScope') === true;
|
|
37
|
+
const buildPrompt = () =>
|
|
38
|
+
buildSystemPrompt(instructions, [systemExtra, scopeDirective(strict)].filter(Boolean).join('\n\n'));
|
|
32
39
|
const resolved = resolveCliProvider(store, config, { provider: values.provider, model: values.model });
|
|
33
40
|
let providerId = resolved.providerId;
|
|
34
41
|
let model = resolved.model;
|
|
@@ -108,7 +115,7 @@ export async function replCommand(argv) {
|
|
|
108
115
|
} else if (cmd === 'help') {
|
|
109
116
|
process.stdout.write(
|
|
110
117
|
`${color.dim(
|
|
111
|
-
'/model <id> /provider <id> /system <texto> /clear /session /exit'
|
|
118
|
+
'/model <id> /provider <id> /system <texto> /strict /clear /session /exit'
|
|
112
119
|
)}\n`
|
|
113
120
|
);
|
|
114
121
|
} else if (cmd === 'model') {
|
|
@@ -118,8 +125,13 @@ export async function replCommand(argv) {
|
|
|
118
125
|
if (arg) providerId = arg;
|
|
119
126
|
process.stdout.write(color.dim(`proveedor: ${providerId}\n`));
|
|
120
127
|
} else if (cmd === 'system') {
|
|
121
|
-
if (arg)
|
|
122
|
-
process.stdout.write(color.dim(`system prompt (${
|
|
128
|
+
if (arg) systemExtra = arg;
|
|
129
|
+
process.stdout.write(color.dim(`system prompt (${buildPrompt().length} chars)\n`));
|
|
130
|
+
} else if (cmd === 'strict' || cmd === 'estricto') {
|
|
131
|
+
if (arg) strict = /^(on|si|sí|1|true)$/i.test(arg);
|
|
132
|
+
else strict = !strict;
|
|
133
|
+
store.setSetting('strictScope', strict);
|
|
134
|
+
process.stdout.write(color.dim(`solo lo pedido: ${strict ? 'activado' : 'desactivado'}\n`));
|
|
123
135
|
} else if (cmd === 'clear') {
|
|
124
136
|
history = [];
|
|
125
137
|
process.stdout.write(color.dim('historial limpiado\n'));
|
|
@@ -142,12 +154,13 @@ export async function replCommand(argv) {
|
|
|
142
154
|
baseUrl,
|
|
143
155
|
apiKey,
|
|
144
156
|
customProviders,
|
|
145
|
-
systemPrompt,
|
|
157
|
+
systemPrompt: buildPrompt(),
|
|
146
158
|
history,
|
|
147
159
|
input: text,
|
|
148
160
|
maxRounds,
|
|
149
161
|
maxTokens: config.maxTokens,
|
|
150
162
|
maxDurationMs: config.maxDurationMs,
|
|
163
|
+
maxOutputTokens: config.maxOutputTokens,
|
|
151
164
|
tools: toolDefs,
|
|
152
165
|
execute,
|
|
153
166
|
onEvent: printer.onEvent,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { loadConfig, loadInstructions, Store, createTools, buildMcpRegistry } from '../lib/core.js';
|
|
2
|
+
import { loadConfig, loadInstructions, Store, createTools, buildMcpRegistry, scopeDirective } from '../lib/core.js';
|
|
3
3
|
import { parse, databasePath, num } from '../lib/util.js';
|
|
4
4
|
import { buildSystemPrompt, runTurn, createPrinter, resolveCliProvider } from '../lib/runtime.js';
|
|
5
5
|
import { color } from '../ui/color.js';
|
|
@@ -15,6 +15,8 @@ const OPTIONS = {
|
|
|
15
15
|
system: { type: 'string' },
|
|
16
16
|
tools: { type: 'string' },
|
|
17
17
|
'no-tools': { type: 'boolean' },
|
|
18
|
+
strict: { type: 'boolean' },
|
|
19
|
+
'no-strict': { type: 'boolean' },
|
|
18
20
|
yes: { type: 'boolean', short: 'y' },
|
|
19
21
|
help: { type: 'boolean', short: 'h' },
|
|
20
22
|
};
|
|
@@ -95,6 +97,8 @@ export async function runCommand(argv) {
|
|
|
95
97
|
const onSigint = () => controller.abort();
|
|
96
98
|
process.once('SIGINT', onSigint);
|
|
97
99
|
|
|
100
|
+
const strict = values.strict ? true : values['no-strict'] ? false : store.getSetting('strictScope') === true;
|
|
101
|
+
|
|
98
102
|
try {
|
|
99
103
|
const { result, newMessages } = await runTurn({
|
|
100
104
|
providerId,
|
|
@@ -102,12 +106,16 @@ export async function runCommand(argv) {
|
|
|
102
106
|
baseUrl,
|
|
103
107
|
apiKey,
|
|
104
108
|
customProviders,
|
|
105
|
-
systemPrompt: buildSystemPrompt(
|
|
109
|
+
systemPrompt: buildSystemPrompt(
|
|
110
|
+
instructions,
|
|
111
|
+
[values.system, scopeDirective(strict)].filter(Boolean).join('\n\n')
|
|
112
|
+
),
|
|
106
113
|
history,
|
|
107
114
|
input: prompt,
|
|
108
115
|
maxRounds: num(values['max-steps'], config.maxRounds),
|
|
109
116
|
maxTokens: config.maxTokens,
|
|
110
117
|
maxDurationMs: config.maxDurationMs,
|
|
118
|
+
maxOutputTokens: config.maxOutputTokens,
|
|
111
119
|
signal: controller.signal,
|
|
112
120
|
tools: toolDefs,
|
|
113
121
|
execute,
|
|
@@ -163,6 +171,7 @@ Opciones:
|
|
|
163
171
|
--max-steps <n> Techo de rondas de herramientas (0 = sin límite, default)
|
|
164
172
|
--tools <lista> Tools habilitadas (coma), ej. read,list,grep
|
|
165
173
|
--no-tools Deshabilitar todas las tools
|
|
174
|
+
--strict Hacer solo lo pedido (estricto) --no-strict
|
|
166
175
|
-y, --yes Aprobar automáticamente los permisos "ask"
|
|
167
176
|
--json Salida JSON (sin streaming)`);
|
|
168
177
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { loadConfig, loadInstructions, Store, buildMcpRegistry, resolveMode } from '../lib/core.js';
|
|
2
|
+
import { loadConfig, loadInstructions, Store, buildMcpRegistry, resolveMode, scopeDirective } from '../lib/core.js';
|
|
3
3
|
import { parse, databasePath, num } from '../lib/util.js';
|
|
4
4
|
import { buildSystemPrompt, resolveCliProvider } from '../lib/runtime.js';
|
|
5
5
|
import { runTui } from '../tui/app.js';
|
|
@@ -14,6 +14,8 @@ const OPTIONS = {
|
|
|
14
14
|
system: { type: 'string' },
|
|
15
15
|
tools: { type: 'string' },
|
|
16
16
|
'no-tools': { type: 'boolean' },
|
|
17
|
+
strict: { type: 'boolean' },
|
|
18
|
+
'no-strict': { type: 'boolean' },
|
|
17
19
|
yes: { type: 'boolean', short: 'y' },
|
|
18
20
|
help: { type: 'boolean', short: 'h' },
|
|
19
21
|
};
|
|
@@ -63,10 +65,11 @@ export async function tuiCommand(argv) {
|
|
|
63
65
|
: undefined;
|
|
64
66
|
|
|
65
67
|
const mode = resolveMode(values.mode || store.getSetting('activeMode') || 'agent');
|
|
68
|
+
let strict = values.strict ? true : values['no-strict'] ? false : store.getSetting('strictScope') === true;
|
|
66
69
|
const enabledFor = (m) =>
|
|
67
70
|
m.tools ? (baseEnabled ? baseEnabled.filter((tool) => m.tools.includes(tool)) : m.tools) : baseEnabled;
|
|
68
71
|
const promptFor = (m) =>
|
|
69
|
-
buildSystemPrompt(instructions, [values.system, m.systemPrompt].filter(Boolean).join('\n\n'));
|
|
72
|
+
buildSystemPrompt(instructions, [values.system, m.systemPrompt, scopeDirective(strict)].filter(Boolean).join('\n\n'));
|
|
70
73
|
const modeContext = (id) => {
|
|
71
74
|
const m = resolveMode(id);
|
|
72
75
|
return { label: m.label, enabled: enabledFor(m), systemPrompt: promptFor(m) };
|
|
@@ -83,10 +86,16 @@ export async function tuiCommand(argv) {
|
|
|
83
86
|
modeId: mode.id,
|
|
84
87
|
modeContext,
|
|
85
88
|
onModeChange: (id) => store.setSetting('activeMode', id),
|
|
89
|
+
strict,
|
|
90
|
+
onStrictChange: (value) => {
|
|
91
|
+
strict = value;
|
|
92
|
+
store.setSetting('strictScope', value);
|
|
93
|
+
},
|
|
86
94
|
systemPrompt: promptFor(mode),
|
|
87
95
|
maxRounds: num(values['max-steps'], config.maxRounds),
|
|
88
96
|
maxTokens: config.maxTokens,
|
|
89
97
|
maxDurationMs: config.maxDurationMs,
|
|
98
|
+
maxOutputTokens: config.maxOutputTokens,
|
|
90
99
|
toolOptions: {
|
|
91
100
|
root: cwd,
|
|
92
101
|
enabled: enabledFor(mode),
|
|
@@ -106,7 +115,7 @@ Consola full-screen (estilo opencode/codex). Atajos:
|
|
|
106
115
|
enter enviar ↑ / ↓ historial
|
|
107
116
|
← → mover cursor home/end inicio / fin
|
|
108
117
|
pgup/pgdn scroll ctrl+l limpiar
|
|
109
|
-
ctrl+. alternar Plan / Build /modo /salir /limpiar
|
|
118
|
+
ctrl+. alternar Plan / Build /modo /estricto /salir /limpiar
|
|
110
119
|
|
|
111
120
|
Opciones:
|
|
112
121
|
--provider <id> Proveedor (por defecto: el activo)
|
|
@@ -115,5 +124,6 @@ Opciones:
|
|
|
115
124
|
--dir <path> Directorio del proyecto
|
|
116
125
|
--max-steps <n> Techo de rondas de herramientas (0 = sin límite, default)
|
|
117
126
|
--tools <lista> Tools habilitadas (coma) --no-tools
|
|
127
|
+
--strict Hacer solo lo pedido (estricto) --no-strict
|
|
118
128
|
-y, --yes Auto-aprobar tools con permiso "ask"`);
|
|
119
129
|
}
|
|
@@ -54,6 +54,7 @@ export async function runTurn({
|
|
|
54
54
|
maxRounds,
|
|
55
55
|
maxTokens,
|
|
56
56
|
maxDurationMs,
|
|
57
|
+
maxOutputTokens,
|
|
57
58
|
stallWarnMs,
|
|
58
59
|
signal,
|
|
59
60
|
execute,
|
|
@@ -80,6 +81,7 @@ export async function runTurn({
|
|
|
80
81
|
maxRounds,
|
|
81
82
|
maxTokens,
|
|
82
83
|
maxDurationMs,
|
|
84
|
+
maxOutputTokens,
|
|
83
85
|
stallWarnMs,
|
|
84
86
|
signal,
|
|
85
87
|
onEvent,
|
|
@@ -17,10 +17,13 @@ export function runTui({
|
|
|
17
17
|
modeId = 'agent',
|
|
18
18
|
modeContext,
|
|
19
19
|
onModeChange,
|
|
20
|
+
strict = false,
|
|
21
|
+
onStrictChange,
|
|
20
22
|
systemPrompt,
|
|
21
23
|
maxRounds,
|
|
22
24
|
maxTokens,
|
|
23
25
|
maxDurationMs,
|
|
26
|
+
maxOutputTokens,
|
|
24
27
|
toolOptions,
|
|
25
28
|
store,
|
|
26
29
|
sessionId,
|
|
@@ -101,6 +104,18 @@ export function runTui({
|
|
|
101
104
|
draw();
|
|
102
105
|
}
|
|
103
106
|
|
|
107
|
+
function setStrict(value) {
|
|
108
|
+
strict = value;
|
|
109
|
+
onStrictChange?.(value);
|
|
110
|
+
setMode(mode);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function toggleStrict(value) {
|
|
114
|
+
setStrict(value == null ? !strict : value);
|
|
115
|
+
addMessage('assistant', `Solo lo pedido: ${strict ? 'activado' : 'desactivado'}.`);
|
|
116
|
+
draw();
|
|
117
|
+
}
|
|
118
|
+
|
|
104
119
|
function draw() {
|
|
105
120
|
if (closed) return;
|
|
106
121
|
const { frame, cursor } = render(state, {
|
|
@@ -153,6 +168,12 @@ export function runTui({
|
|
|
153
168
|
draw();
|
|
154
169
|
return;
|
|
155
170
|
}
|
|
171
|
+
if (text === '/estricto' || text === '/strict') return toggleStrict();
|
|
172
|
+
const strictArg = text.match(/^\/(?:estricto|strict)\s+(on|off|si|sí|no)$/i);
|
|
173
|
+
if (strictArg) {
|
|
174
|
+
toggleStrict(/^(on|si|sí)$/i.test(strictArg[1]));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
156
177
|
if (text === '/limpiar' || text === '/clear') {
|
|
157
178
|
state.input = '';
|
|
158
179
|
state.messages = [];
|
|
@@ -184,6 +205,7 @@ export function runTui({
|
|
|
184
205
|
maxRounds,
|
|
185
206
|
maxTokens,
|
|
186
207
|
maxDurationMs,
|
|
208
|
+
maxOutputTokens,
|
|
187
209
|
signal: controller.signal,
|
|
188
210
|
tools: toolset.definitions,
|
|
189
211
|
execute: toolset.execute,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Compactación de contexto (auto-compact). La lógica de decisión es pura y
|
|
2
|
+
// testeable; la llamada al modelo para resumir vive en el server.
|
|
3
|
+
|
|
4
|
+
import { estimateTokens } from './context.js';
|
|
5
|
+
|
|
6
|
+
export const COMPACT_INSTRUCTION =
|
|
7
|
+
'Resumí la conversación en un texto conciso que preserve decisiones tomadas, archivos tocados, hallazgos y el estado actual, para poder continuar el trabajo sin el historial completo.';
|
|
8
|
+
|
|
9
|
+
export const SUMMARY_INSTRUCTION =
|
|
10
|
+
'Resumí la conversación para la persona: qué se pidió, qué se hizo, decisiones tomadas, archivos o comandos relevantes y el estado actual. Sé claro y conciso, en español, sin markdown pesado.';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Historial vigente: sin los mensajes del asesor ni lo ya resumido (anterior al
|
|
14
|
+
* cursor de compactación). Si el cursor no está (historial truncado), devuelve
|
|
15
|
+
* todo para no perder contexto.
|
|
16
|
+
*/
|
|
17
|
+
export function liveHistory(history = [], cursor = null) {
|
|
18
|
+
const filtered = history.filter((message) => message.name !== 'advisor');
|
|
19
|
+
if (!cursor) return filtered;
|
|
20
|
+
const idx = filtered.findIndex((message) => message.id === cursor);
|
|
21
|
+
return idx === -1 ? filtered : filtered.slice(idx + 1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Decide si conviene compactar, sobre el historial vigente. Puro (no llama al
|
|
26
|
+
* modelo). Devuelve qué resumir, qué conservar y el cursor (id del último
|
|
27
|
+
* mensaje resumido), o `null` si no hace falta.
|
|
28
|
+
*/
|
|
29
|
+
export function planCompaction(live, { contextTokens = 0, threshold = 0.75, keepLast = 8 } = {}) {
|
|
30
|
+
if (!(contextTokens > 0)) return null;
|
|
31
|
+
const keep = Math.max(0, Number(keepLast) || 0);
|
|
32
|
+
if (!Array.isArray(live) || live.length <= keep + 2) return null;
|
|
33
|
+
if (estimateTokens(live) < contextTokens * threshold) return null;
|
|
34
|
+
const toSummarize = live.slice(0, live.length - keep);
|
|
35
|
+
const cursor = toSummarize.at(-1)?.id;
|
|
36
|
+
if (!cursor) return null;
|
|
37
|
+
return { toSummarize, keep: live.slice(live.length - keep), cursor };
|
|
38
|
+
}
|
|
@@ -40,9 +40,14 @@ export function trimContext(messages, { maxTokens, keepLast = 8 } = {}) {
|
|
|
40
40
|
if (estimateTokens(messages) <= maxTokens) return { messages, trimmed: 0, dropped: 0 };
|
|
41
41
|
|
|
42
42
|
const out = messages.map((message) => ({ ...message }));
|
|
43
|
+
// Prefijo protegido: los system iniciales (base + objetivo + resumen de
|
|
44
|
+
// compactación). Nunca se recortan ni se descartan: son contexto esencial.
|
|
45
|
+
let head = 1;
|
|
46
|
+
while (head < out.length && out[head].role === 'system') head += 1;
|
|
47
|
+
|
|
43
48
|
let trimmed = 0;
|
|
44
|
-
const cutoff = Math.max(
|
|
45
|
-
for (let i =
|
|
49
|
+
const cutoff = Math.max(head, out.length - keepLast);
|
|
50
|
+
for (let i = head; i < cutoff; i++) {
|
|
46
51
|
const message = out[i];
|
|
47
52
|
if (message.role === 'tool' && typeof message.content === 'string' && message.content.length > TOOL_KEEP_CHARS) {
|
|
48
53
|
out[i] = { ...message, content: `${message.content.slice(0, TOOL_KEEP_CHARS)}\n…[recortado por contexto]` };
|
|
@@ -52,15 +57,15 @@ export function trimContext(messages, { maxTokens, keepLast = 8 } = {}) {
|
|
|
52
57
|
if (estimateTokens(out) <= maxTokens) return { messages: out, trimmed, dropped: 0 };
|
|
53
58
|
|
|
54
59
|
let dropped = 0;
|
|
55
|
-
while (estimateTokens(out) > maxTokens && out.length > keepLast
|
|
56
|
-
const first = out[
|
|
57
|
-
let end =
|
|
60
|
+
while (estimateTokens(out) > maxTokens && out.length - head > keepLast) {
|
|
61
|
+
const first = out[head];
|
|
62
|
+
let end = head + 1;
|
|
58
63
|
if (first.role === 'assistant' && Array.isArray(first.tool_calls)) {
|
|
59
64
|
while (end < out.length && out[end].role === 'tool') end += 1;
|
|
60
65
|
}
|
|
61
|
-
if (out.length - (end -
|
|
62
|
-
out.splice(
|
|
63
|
-
dropped += end -
|
|
66
|
+
if (out.length - (end - head) < keepLast) break;
|
|
67
|
+
out.splice(head, end - head);
|
|
68
|
+
dropped += end - head;
|
|
64
69
|
}
|
|
65
70
|
return { messages: out, trimmed, dropped };
|
|
66
71
|
}
|
|
@@ -57,14 +57,19 @@ export async function runAgent({
|
|
|
57
57
|
maxRounds = MAX_TOOL_ROUNDS,
|
|
58
58
|
maxTokens = 0,
|
|
59
59
|
maxDurationMs = 0,
|
|
60
|
+
maxOutputTokens = 0,
|
|
61
|
+
promptCaching = false,
|
|
60
62
|
repeatLimit = 3,
|
|
61
63
|
wrapUp = true,
|
|
62
64
|
stallWarnMs = 90000,
|
|
63
65
|
retries = 2,
|
|
64
66
|
retryDelayMs = 500,
|
|
65
67
|
contextTokens = 0,
|
|
68
|
+
onLifecycle,
|
|
66
69
|
}) {
|
|
67
70
|
const working = [...messages];
|
|
71
|
+
const emitLifecycle = (type, payload) => onLifecycle?.({ type, ...payload });
|
|
72
|
+
emitLifecycle('run.start', { model });
|
|
68
73
|
let usageTotal = null;
|
|
69
74
|
const startedAt = Date.now();
|
|
70
75
|
const fingerprints = new Map();
|
|
@@ -129,6 +134,8 @@ export async function runAgent({
|
|
|
129
134
|
messages: contextMessages,
|
|
130
135
|
tools,
|
|
131
136
|
signal,
|
|
137
|
+
maxOutputTokens,
|
|
138
|
+
promptCaching,
|
|
132
139
|
onEvent: (event) => {
|
|
133
140
|
if (event && (event.type === 'reasoning' || event.type === 'tool_calls')) emitted = true;
|
|
134
141
|
if (event?.type === 'text' && event.delta) emitted = true;
|
|
@@ -182,6 +189,7 @@ export async function runAgent({
|
|
|
182
189
|
content: 'Cancelado por el usuario.',
|
|
183
190
|
});
|
|
184
191
|
}
|
|
192
|
+
emitLifecycle('run.end', { stopReason: 'aborted', usage: usageTotal });
|
|
185
193
|
return {
|
|
186
194
|
content: result.content ?? '',
|
|
187
195
|
reasoning: result.reasoning ?? '',
|
|
@@ -208,11 +216,13 @@ export async function runAgent({
|
|
|
208
216
|
output = NO_PROGRESS_MESSAGE;
|
|
209
217
|
} else {
|
|
210
218
|
newCalls++;
|
|
219
|
+
emitLifecycle('tool.before', { id: call.id, name, args });
|
|
211
220
|
try {
|
|
212
221
|
output = await execute(name, args, toolContext);
|
|
213
222
|
} catch (err) {
|
|
214
223
|
output = `Error ejecutando ${name}: ${err?.message ?? err}`;
|
|
215
224
|
}
|
|
225
|
+
emitLifecycle('tool.after', { id: call.id, name, args, result: output });
|
|
216
226
|
}
|
|
217
227
|
|
|
218
228
|
const normalized = normalizeToolOutput(output);
|
|
@@ -244,7 +254,7 @@ export async function runAgent({
|
|
|
244
254
|
|
|
245
255
|
let wrappedUp = false;
|
|
246
256
|
if (wrapUp && (stopReason === 'max_rounds' || stopReason === 'no_progress' || stopReason === 'budget')) {
|
|
247
|
-
const wrap = await runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries: retries, retryDelayMs });
|
|
257
|
+
const wrap = await runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries: retries, retryDelayMs, maxOutputTokens, promptCaching });
|
|
248
258
|
if (wrap.usage) accumulateUsage(wrap.usage);
|
|
249
259
|
if (wrap.content) {
|
|
250
260
|
working.push({ role: 'assistant', content: wrap.content, tokens_in: wrap.usage?.prompt_tokens, tokens_out: wrap.usage?.completion_tokens });
|
|
@@ -253,6 +263,7 @@ export async function runAgent({
|
|
|
253
263
|
}
|
|
254
264
|
}
|
|
255
265
|
|
|
266
|
+
emitLifecycle('run.end', { stopReason, usage: usageTotal });
|
|
256
267
|
return {
|
|
257
268
|
content,
|
|
258
269
|
reasoning: '',
|
|
@@ -266,7 +277,7 @@ export async function runAgent({
|
|
|
266
277
|
}
|
|
267
278
|
|
|
268
279
|
/** Ronda final con tools deshabilitadas para forzar un cierre con texto. */
|
|
269
|
-
async function runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries, retryDelayMs }) {
|
|
280
|
+
async function runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries, retryDelayMs, maxOutputTokens, promptCaching }) {
|
|
270
281
|
try {
|
|
271
282
|
const result = await withRetry(
|
|
272
283
|
() =>
|
|
@@ -275,6 +286,8 @@ async function runFinalAnswer({ provider, config, model, working, signal, onEven
|
|
|
275
286
|
messages: [...working, { role: 'system', content: WRAP_UP_PROMPT }],
|
|
276
287
|
tools: [],
|
|
277
288
|
signal,
|
|
289
|
+
maxOutputTokens,
|
|
290
|
+
promptCaching,
|
|
278
291
|
onEvent,
|
|
279
292
|
}),
|
|
280
293
|
{ retries: maxRetries, baseDelayMs: retryDelayMs, signal }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Alcance estricto: cuando esta activo, el system prompt pide hacer exactamente
|
|
2
|
+
// lo solicitado, sin mejoras, refactors ni cambios fuera del pedido.
|
|
3
|
+
|
|
4
|
+
export const STRICT_SCOPE_PROMPT = [
|
|
5
|
+
'Modo estricto (solo lo pedido): hacé EXACTAMENTE lo que se te pide, nada más.',
|
|
6
|
+
'- No agregues mejoras, refactors, archivos, dependencias ni cambios que no se hayan pedido explícitamente.',
|
|
7
|
+
'- No amplíes el alcance: si el pedido menciona un archivo o una función, limitate a eso.',
|
|
8
|
+
'- Si para cumplir el pedido hiciera falta algo extra, preguntá antes de hacerlo.',
|
|
9
|
+
'- Preferí el cambio mínimo que resuelve el pedido y no toques código no relacionado.',
|
|
10
|
+
].join('\n');
|
|
11
|
+
|
|
12
|
+
/** Bloque de prompt del alcance estricto, o cadena vacía si está desactivado. */
|
|
13
|
+
export function scopeDirective(enabled) {
|
|
14
|
+
return enabled ? STRICT_SCOPE_PROMPT : '';
|
|
15
|
+
}
|
|
@@ -10,6 +10,10 @@ export const DEFAULT_CONFIG = {
|
|
|
10
10
|
maxRounds: 0,
|
|
11
11
|
maxTokens: 0,
|
|
12
12
|
maxDurationMs: 0,
|
|
13
|
+
maxOutputTokens: 0,
|
|
14
|
+
promptCaching: false,
|
|
15
|
+
skills: { mode: 'lazy' },
|
|
16
|
+
compact: { enabled: true, threshold: 0.75, keepLast: 8 },
|
|
13
17
|
tools: [],
|
|
14
18
|
permissions: {},
|
|
15
19
|
mcp: [],
|
|
@@ -12,10 +12,12 @@ export { resolveApiKey, resolveEnvMap } from './config/secrets.js';
|
|
|
12
12
|
export { ensureUtf8Console } from './console.js';
|
|
13
13
|
export { MAX_TOOL_ROUNDS, runAgent, parseToolArguments, normalizeToolOutput } from './agent/runAgent.js';
|
|
14
14
|
export { estimateTokens, trimContext } from './agent/context.js';
|
|
15
|
+
export { liveHistory, planCompaction, COMPACT_INSTRUCTION, SUMMARY_INSTRUCTION } from './agent/compact.js';
|
|
15
16
|
export { diffLines, diffRows } from './tools/diff.js';
|
|
16
17
|
export { isGitRepo, gitStatus, gitDiff } from './git/index.js';
|
|
17
18
|
export { summarizeToolCall, previewToolResult } from './tools/labels.js';
|
|
18
19
|
export { MODES, MODE_IDS, resolveMode } from './agent/modes.js';
|
|
20
|
+
export { STRICT_SCOPE_PROMPT, scopeDirective } from './agent/scope.js';
|
|
19
21
|
export { withRetry, isRetryableError, isAbortError } from './providers/resilience.js';
|
|
20
22
|
export {
|
|
21
23
|
PROVIDERS,
|
|
@@ -86,7 +88,14 @@ export { summarize } from './eval/report.js';
|
|
|
86
88
|
export { runScorers, validateSchema, extractJson, SCORERS } from './eval/scorers.js';
|
|
87
89
|
export { createJudge } from './eval/judge.js';
|
|
88
90
|
export { createTaskWorkspace, normalizeExpect } from './eval/task.js';
|
|
89
|
-
export {
|
|
91
|
+
export {
|
|
92
|
+
parseSkillMarkdown,
|
|
93
|
+
loadSkills,
|
|
94
|
+
mergeSkills,
|
|
95
|
+
writeSkillFiles,
|
|
96
|
+
buildSkillsPrompt,
|
|
97
|
+
buildSkillsIndex,
|
|
98
|
+
} from './skills/index.js';
|
|
90
99
|
export { parseSkillRef, fetchSkillFromGithub } from './skills/remote.js';
|
|
91
100
|
export {
|
|
92
101
|
searchMcpRegistry,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Prompt caching. `mode` es una capability del provider:
|
|
2
|
+
// - 'anthropic': marca el system prompt con cache_control (OpenRouter y la capa
|
|
3
|
+
// OpenAI-compatible de Anthropic aceptan bloques de contenido con cache_control).
|
|
4
|
+
// - 'openai': expone una clave estable para el cacheo automático de prefijos.
|
|
5
|
+
// Es opt-in: sin `promptCaching` en la config, no se toca nada.
|
|
6
|
+
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
/** Clave estable (system + tools) para el cacheo automático de OpenAI. */
|
|
10
|
+
export function promptCacheKey(messages = [], tools = []) {
|
|
11
|
+
const base = JSON.stringify([messages?.[0]?.content ?? '', tools ?? []]);
|
|
12
|
+
return createHash('sha1').update(base).digest('hex').slice(0, 32);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Aplica las pistas de cache al body (muta y devuelve `body`). */
|
|
16
|
+
export function applyPromptCache(body, { mode = 'none' } = {}) {
|
|
17
|
+
if (mode === 'anthropic' && Array.isArray(body.messages) && body.messages.length) {
|
|
18
|
+
const first = body.messages[0];
|
|
19
|
+
if (first?.role === 'system' && typeof first.content === 'string') {
|
|
20
|
+
body.messages = [
|
|
21
|
+
{ ...first, content: [{ type: 'text', text: first.content, cache_control: { type: 'ephemeral' } }] },
|
|
22
|
+
...body.messages.slice(1),
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
} else if (mode === 'openai') {
|
|
26
|
+
body.prompt_cache_key = promptCacheKey(body.messages, body.tools);
|
|
27
|
+
}
|
|
28
|
+
return body;
|
|
29
|
+
}
|
|
@@ -52,9 +52,10 @@ export const ollamaProvider = {
|
|
|
52
52
|
return sortModels((data.models ?? []).map(toOllamaModel).filter((m) => m.id));
|
|
53
53
|
},
|
|
54
54
|
|
|
55
|
-
async streamChat({ baseUrl }, { model, messages, tools, signal, onEvent }) {
|
|
55
|
+
async streamChat({ baseUrl }, { model, messages, tools, signal, onEvent, maxOutputTokens }) {
|
|
56
56
|
const body = { model, messages: toOllamaMessages(messages), stream: true };
|
|
57
57
|
if (tools?.length) body.tools = tools;
|
|
58
|
+
if (maxOutputTokens > 0) body.options = { ...(body.options ?? {}), num_predict: maxOutputTokens };
|
|
58
59
|
|
|
59
60
|
const res = await providerFetch(joinUrl(baseUrl, '/api/chat'), {
|
|
60
61
|
method: 'POST',
|
|
@@ -71,6 +72,6 @@ export const ollamaProvider = {
|
|
|
71
72
|
});
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
return readNDJSON(res, { onEvent });
|
|
75
|
+
return readNDJSON(res, { onEvent, signal });
|
|
75
76
|
},
|
|
76
77
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readOpenAISSE } from './stream.js';
|
|
2
|
+
import { applyPromptCache } from './cache.js';
|
|
2
3
|
import { joinUrl, authHeaders, providerError, providerFetch, normalizeModel, sortModels } from './utils.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -21,9 +22,15 @@ export const openaiCompatibleProvider = {
|
|
|
21
22
|
return sortModels(list.map(normalizeModel).filter((m) => m.id));
|
|
22
23
|
},
|
|
23
24
|
|
|
24
|
-
async streamChat({ baseUrl, apiKey, headers }, { model, messages, tools, signal, onEvent }) {
|
|
25
|
+
async streamChat({ baseUrl, apiKey, headers }, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching }) {
|
|
25
26
|
const body = { model, messages, stream: true };
|
|
26
27
|
if (tools?.length) body.tools = tools;
|
|
28
|
+
if (maxOutputTokens > 0) {
|
|
29
|
+
// OpenAI o-series exige `max_completion_tokens`; el resto usa `max_tokens`.
|
|
30
|
+
const field = this?.maxTokensField ?? 'max_tokens';
|
|
31
|
+
body[field] = maxOutputTokens;
|
|
32
|
+
}
|
|
33
|
+
if (promptCaching) applyPromptCache(body, { mode: this?.caching ?? 'none' });
|
|
27
34
|
|
|
28
35
|
return requestStream({
|
|
29
36
|
url: joinUrl(baseUrl, '/chat/completions'),
|
|
@@ -53,5 +60,5 @@ export async function requestStream({ url, headers, providerLabel, model, body,
|
|
|
53
60
|
});
|
|
54
61
|
}
|
|
55
62
|
|
|
56
|
-
return readOpenAISSE(res, { onEvent });
|
|
63
|
+
return readOpenAISSE(res, { onEvent, signal });
|
|
57
64
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { requestStream } from './openaiCompatible.js';
|
|
2
|
+
import { applyPromptCache } from './cache.js';
|
|
2
3
|
import { joinUrl, providerFetch, normalizeModel, sortModels } from './utils.js';
|
|
3
4
|
|
|
4
5
|
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
@@ -21,6 +22,8 @@ export const openrouterProvider = {
|
|
|
21
22
|
label: 'OpenRouter',
|
|
22
23
|
defaultBaseUrl: OPENROUTER_BASE_URL,
|
|
23
24
|
requiresKey: true,
|
|
25
|
+
// OpenRouter pasa cache_control a Anthropic; en otros modelos queda sin efecto.
|
|
26
|
+
caching: 'anthropic',
|
|
24
27
|
|
|
25
28
|
async listModels({ apiKey, siteUrl, siteName }) {
|
|
26
29
|
const res = await providerFetch(joinUrl(OPENROUTER_BASE_URL, '/models'), {
|
|
@@ -40,9 +43,11 @@ export const openrouterProvider = {
|
|
|
40
43
|
);
|
|
41
44
|
},
|
|
42
45
|
|
|
43
|
-
async streamChat(ctx, { model, messages, tools, signal, onEvent }) {
|
|
46
|
+
async streamChat(ctx, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching }) {
|
|
44
47
|
const body = { model, messages, stream: true };
|
|
45
48
|
if (tools?.length) body.tools = tools;
|
|
49
|
+
if (maxOutputTokens > 0) body.max_tokens = maxOutputTokens;
|
|
50
|
+
if (promptCaching) applyPromptCache(body, { mode: this?.caching ?? 'none' });
|
|
46
51
|
|
|
47
52
|
return requestStream({
|
|
48
53
|
url: joinUrl(OPENROUTER_BASE_URL, '/chat/completions'),
|