@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
|
@@ -2,16 +2,19 @@ import { openaiCompatibleProvider } from './openaiCompatible.js';
|
|
|
2
2
|
|
|
3
3
|
// Most commercial providers expose an OpenAI-compatible chat completions API,
|
|
4
4
|
// so they only differ by label and base URL. Adding one is a one-liner.
|
|
5
|
-
function preset(id, label, baseUrl) {
|
|
6
|
-
return { ...openaiCompatibleProvider, id, label, defaultBaseUrl: baseUrl, requiresKey: true };
|
|
5
|
+
function preset(id, label, baseUrl, extra = {}) {
|
|
6
|
+
return { ...openaiCompatibleProvider, id, label, defaultBaseUrl: baseUrl, requiresKey: true, ...extra };
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export const OPENAI_COMPATIBLE_PRESETS = [
|
|
10
10
|
preset('deepseek', 'DeepSeek', 'https://api.deepseek.com/v1'),
|
|
11
|
-
preset('openai', 'OpenAI', 'https://api.openai.com/v1'
|
|
11
|
+
preset('openai', 'OpenAI', 'https://api.openai.com/v1', {
|
|
12
|
+
maxTokensField: 'max_completion_tokens',
|
|
13
|
+
caching: 'openai',
|
|
14
|
+
}),
|
|
12
15
|
// Anthropic y Gemini exponen una capa compatible con OpenAI (chat/completions
|
|
13
16
|
// + tools) que usa el mismo Bearer; sirven con este adaptador sin cambios.
|
|
14
|
-
preset('anthropic', 'Anthropic (Claude)', 'https://api.anthropic.com/v1'),
|
|
17
|
+
preset('anthropic', 'Anthropic (Claude)', 'https://api.anthropic.com/v1', { caching: 'anthropic' }),
|
|
15
18
|
preset('gemini', 'Google Gemini', 'https://generativelanguage.googleapis.com/v1beta/openai'),
|
|
16
19
|
preset('groq', 'Groq', 'https://api.groq.com/openai/v1'),
|
|
17
20
|
preset('mistral', 'Mistral AI', 'https://api.mistral.ai/v1'),
|
|
@@ -9,6 +9,47 @@
|
|
|
9
9
|
* string or as an object (`message`, `data.message`, serialized `data`).
|
|
10
10
|
* (openbridge-style tolerance: errors come in many shapes per provider.)
|
|
11
11
|
*/
|
|
12
|
+
function abortError() {
|
|
13
|
+
return new DOMException('Aborted', 'AbortError');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `reader.read()` abortable. Aunque `fetch` deberia cancelar el body al abortar,
|
|
18
|
+
* no todos los runtimes lo hacen de forma inmediata; esto garantiza que el
|
|
19
|
+
* stream se corte apenas el usuario detiene el run.
|
|
20
|
+
*/
|
|
21
|
+
function readChunk(reader, signal) {
|
|
22
|
+
if (!signal) return reader.read();
|
|
23
|
+
if (signal.aborted) {
|
|
24
|
+
reader.cancel().catch(() => {});
|
|
25
|
+
return Promise.reject(abortError());
|
|
26
|
+
}
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
let settled = false;
|
|
29
|
+
const onAbort = () => {
|
|
30
|
+
if (settled) return;
|
|
31
|
+
settled = true;
|
|
32
|
+
reader.cancel().catch(() => {});
|
|
33
|
+
reject(abortError());
|
|
34
|
+
};
|
|
35
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
36
|
+
reader.read().then(
|
|
37
|
+
(result) => {
|
|
38
|
+
if (settled) return;
|
|
39
|
+
settled = true;
|
|
40
|
+
signal.removeEventListener('abort', onAbort);
|
|
41
|
+
resolve(result);
|
|
42
|
+
},
|
|
43
|
+
(error) => {
|
|
44
|
+
if (settled) return;
|
|
45
|
+
settled = true;
|
|
46
|
+
signal.removeEventListener('abort', onAbort);
|
|
47
|
+
reject(error);
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
12
53
|
export function providerErrorMessage(payload) {
|
|
13
54
|
const err = typeof payload === 'string' ? payload : (payload?.error ?? payload);
|
|
14
55
|
if (typeof err === 'string' && err) return err;
|
|
@@ -39,7 +80,7 @@ export function providerErrorMessage(payload) {
|
|
|
39
80
|
* Parse an OpenAI-compatible SSE stream.
|
|
40
81
|
* @returns {Promise<{content:string, reasoning:string, toolCalls:object[], usage:object|null}>}
|
|
41
82
|
*/
|
|
42
|
-
export async function readOpenAISSE(response, { onEvent } = {}) {
|
|
83
|
+
export async function readOpenAISSE(response, { onEvent, signal } = {}) {
|
|
43
84
|
const reader = response.body.getReader();
|
|
44
85
|
const decoder = new TextDecoder('utf-8');
|
|
45
86
|
let partial = '';
|
|
@@ -50,7 +91,7 @@ export async function readOpenAISSE(response, { onEvent } = {}) {
|
|
|
50
91
|
let usage = null;
|
|
51
92
|
|
|
52
93
|
while (true) {
|
|
53
|
-
const { done, value } = await reader
|
|
94
|
+
const { done, value } = await readChunk(reader, signal);
|
|
54
95
|
if (done) break;
|
|
55
96
|
partial += decoder.decode(value, { stream: true });
|
|
56
97
|
const lines = partial.split('\n');
|
|
@@ -108,7 +149,7 @@ export async function readOpenAISSE(response, { onEvent } = {}) {
|
|
|
108
149
|
* Parse Ollama's native NDJSON stream (`/api/chat`).
|
|
109
150
|
* @returns {Promise<{content:string, reasoning:string, toolCalls:object[], usage:object|null}>}
|
|
110
151
|
*/
|
|
111
|
-
export async function readNDJSON(response, { onEvent } = {}) {
|
|
152
|
+
export async function readNDJSON(response, { onEvent, signal } = {}) {
|
|
112
153
|
const reader = response.body.getReader();
|
|
113
154
|
const decoder = new TextDecoder('utf-8');
|
|
114
155
|
let partial = '';
|
|
@@ -155,7 +196,7 @@ export async function readNDJSON(response, { onEvent } = {}) {
|
|
|
155
196
|
};
|
|
156
197
|
|
|
157
198
|
while (true) {
|
|
158
|
-
const { done, value } = await reader
|
|
199
|
+
const { done, value } = await readChunk(reader, signal);
|
|
159
200
|
if (done) break;
|
|
160
201
|
partial += decoder.decode(value, { stream: true });
|
|
161
202
|
const lines = partial.split('\n');
|
|
@@ -77,6 +77,27 @@ export async function writeSkillFiles(dir, files = []) {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
const INDEX_WHEN_CHARS = 220;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Índice compacto (name + when/description, sin body) para *progressive
|
|
84
|
+
* disclosure*: el modelo ve qué skills hay y carga el body on-demand con la
|
|
85
|
+
* herramienta `skill`. Mucho más barato en tokens que inyectar todos los bodies.
|
|
86
|
+
*/
|
|
87
|
+
export function buildSkillsIndex(skills = []) {
|
|
88
|
+
if (!skills.length) return '';
|
|
89
|
+
const lines = [
|
|
90
|
+
'Tenés skills disponibles (instrucciones especializadas). Cuando una aplique a la tarea, cargá su contenido con la herramienta `skill({ name })`:',
|
|
91
|
+
];
|
|
92
|
+
for (const skill of skills) {
|
|
93
|
+
const title = skill.name || skill.id;
|
|
94
|
+
const when = String(skill.when || skill.description || '').replace(/\s+/g, ' ').trim();
|
|
95
|
+
const hint = when.length > INDEX_WHEN_CHARS ? `${when.slice(0, INDEX_WHEN_CHARS)}…` : when;
|
|
96
|
+
lines.push(`- ${skill.id}: ${title}${hint ? ` — ${hint}` : ''}`);
|
|
97
|
+
}
|
|
98
|
+
return lines.join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
80
101
|
/** Text block appended to the system prompt with the enabled skills. */
|
|
81
102
|
export function buildSkillsPrompt(skills = []) {
|
|
82
103
|
if (!skills.length) return '';
|
|
@@ -301,12 +301,16 @@ export async function grepTool(root, { pattern, include, path: p } = {}) {
|
|
|
301
301
|
return results.length ? results.join('\n') : 'Sin coincidencias';
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
export function bashTool(root, { command, timeout } = {}) {
|
|
304
|
+
export function bashTool(root, { command, timeout } = {}, { signal } = {}) {
|
|
305
305
|
return new Promise((resolve) => {
|
|
306
306
|
if (!command || typeof command !== 'string') {
|
|
307
307
|
resolve('Error: falta "command".');
|
|
308
308
|
return;
|
|
309
309
|
}
|
|
310
|
+
if (signal?.aborted) {
|
|
311
|
+
resolve('Comando cancelado por el usuario.');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
310
314
|
const seconds = Math.min(Math.max(Number(timeout) || 30, 1), 300);
|
|
311
315
|
let child;
|
|
312
316
|
try {
|
|
@@ -318,31 +322,35 @@ export function bashTool(root, { command, timeout } = {}) {
|
|
|
318
322
|
|
|
319
323
|
let output = '';
|
|
320
324
|
let settled = false;
|
|
325
|
+
let timer = null;
|
|
321
326
|
const append = (chunk) => {
|
|
322
327
|
if (output.length < MAX_BASH_OUTPUT) output += chunk.toString();
|
|
323
328
|
};
|
|
324
329
|
|
|
325
|
-
const
|
|
330
|
+
const finish = (text) => {
|
|
326
331
|
if (settled) return;
|
|
327
332
|
settled = true;
|
|
333
|
+
if (timer) clearTimeout(timer);
|
|
334
|
+
signal?.removeEventListener('abort', onAbort);
|
|
335
|
+
resolve(text);
|
|
336
|
+
};
|
|
337
|
+
const onAbort = () => {
|
|
328
338
|
child.kill('SIGKILL');
|
|
329
|
-
|
|
339
|
+
finish(`${output}\n[comando cancelado por el usuario]\n[exit -1]`);
|
|
340
|
+
};
|
|
341
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
342
|
+
|
|
343
|
+
timer = setTimeout(() => {
|
|
344
|
+
child.kill('SIGKILL');
|
|
345
|
+
finish(`${output}\n[timeout tras ${seconds}s]\n[exit -1]`);
|
|
330
346
|
}, seconds * 1000);
|
|
331
347
|
|
|
332
348
|
child.stdout?.on('data', append);
|
|
333
349
|
child.stderr?.on('data', append);
|
|
334
|
-
child.on('error', (err) => {
|
|
335
|
-
if (settled) return;
|
|
336
|
-
settled = true;
|
|
337
|
-
clearTimeout(timer);
|
|
338
|
-
resolve(`Error ejecutando el comando: ${err.message}`);
|
|
339
|
-
});
|
|
350
|
+
child.on('error', (err) => finish(`Error ejecutando el comando: ${err.message}`));
|
|
340
351
|
child.on('close', (code) => {
|
|
341
|
-
if (settled) return;
|
|
342
|
-
settled = true;
|
|
343
|
-
clearTimeout(timer);
|
|
344
352
|
const truncated = output.length >= MAX_BASH_OUTPUT ? '\n[output truncado]' : '';
|
|
345
|
-
|
|
353
|
+
finish(`${output}${truncated}\n[exit ${code}]`);
|
|
346
354
|
});
|
|
347
355
|
});
|
|
348
356
|
}
|
|
@@ -18,8 +18,8 @@ export function createTools({ mcp, ...registryOptions } = {}) {
|
|
|
18
18
|
const registry = createToolRegistry(registryOptions);
|
|
19
19
|
const definitions = [...registry.definitions, ...(mcp?.definitions ?? [])];
|
|
20
20
|
|
|
21
|
-
async function execute(name, args) {
|
|
22
|
-
if (registry.handles(name)) return registry.execute(name, args);
|
|
21
|
+
async function execute(name, args, context) {
|
|
22
|
+
if (registry.handles(name)) return registry.execute(name, args, context);
|
|
23
23
|
if (mcp?.handles?.(name)) return mcp.call(name, args);
|
|
24
24
|
return `Error: herramienta desconocida "${name}".`;
|
|
25
25
|
}
|
|
@@ -11,8 +11,9 @@ import { evaluatePermission } from './permissions.js';
|
|
|
11
11
|
* @param {object} [opts.permissions] - policy per tool ('allow'|'ask'|'deny' or pattern map)
|
|
12
12
|
* @param {(name:string, args:object)=>Promise<boolean>} [opts.onAsk] - interactive approval
|
|
13
13
|
* @param {boolean} [opts.yes] - auto-approve 'ask' (non-interactive)
|
|
14
|
+
* @param {AbortSignal} [opts.signal] - cancela las tools de larga duracion (bash, git)
|
|
14
15
|
*/
|
|
15
|
-
export function createToolRegistry({ root, enabled, permissions = {}, onAsk, yes = false } = {}) {
|
|
16
|
+
export function createToolRegistry({ root, enabled, permissions = {}, onAsk, yes = false, signal } = {}) {
|
|
16
17
|
const names = new Set(enabled?.length ? enabled : TOOL_NAMES);
|
|
17
18
|
const definitions = TOOL_DEFINITIONS.filter((d) => names.has(d.function.name));
|
|
18
19
|
|
|
@@ -31,7 +32,7 @@ export function createToolRegistry({ root, enabled, permissions = {}, onAsk, yes
|
|
|
31
32
|
return `Permiso denegado para "${name}"${detail ? ` (${detail})` : ''}.`;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
return EXECUTORS[name](root, args);
|
|
35
|
+
return EXECUTORS[name](root, args, { signal });
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
return {
|
|
@@ -357,3 +357,41 @@ test('tool_result propaga el meta del executor sin ensuciar el contenido', async
|
|
|
357
357
|
assert.equal(result.result, 'captura');
|
|
358
358
|
assert.deepEqual(result.meta, { screenshot: { name: 'a.png' } });
|
|
359
359
|
});
|
|
360
|
+
|
|
361
|
+
test('runAgent propaga maxOutputTokens al proveedor', async () => {
|
|
362
|
+
const seen = [];
|
|
363
|
+
const provider = {
|
|
364
|
+
async streamChat(_config, params) {
|
|
365
|
+
seen.push(params.maxOutputTokens);
|
|
366
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
await runAgent({
|
|
370
|
+
provider,
|
|
371
|
+
config: {},
|
|
372
|
+
model: 'm',
|
|
373
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
374
|
+
maxOutputTokens: 1234,
|
|
375
|
+
});
|
|
376
|
+
assert.deepEqual(seen, [1234]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('runAgent emite el ciclo de vida (run.start/end, tool.before/after)', async () => {
|
|
380
|
+
const life = [];
|
|
381
|
+
const provider = {
|
|
382
|
+
async streamChat(_config, { messages }) {
|
|
383
|
+
if (messages.some((m) => m.role === 'tool')) return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
384
|
+
return { content: '', reasoning: '', toolCalls: [{ id: 'c1', function: { name: 'step', arguments: '{}' } }] };
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
await runAgent({
|
|
388
|
+
provider,
|
|
389
|
+
config: {},
|
|
390
|
+
model: 'm',
|
|
391
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
392
|
+
tools: [{ type: 'function', function: { name: 'step', parameters: {} } }],
|
|
393
|
+
execute: async () => 'ok',
|
|
394
|
+
onLifecycle: (event) => life.push(event.type),
|
|
395
|
+
});
|
|
396
|
+
assert.deepEqual(life, ['run.start', 'tool.before', 'tool.after', 'run.end']);
|
|
397
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { liveHistory, planCompaction } from '../src/agent/compact.js';
|
|
4
|
+
|
|
5
|
+
test('liveHistory saca al asesor y recorta hasta el cursor', () => {
|
|
6
|
+
const history = [
|
|
7
|
+
{ id: 'a', role: 'user', content: '1' },
|
|
8
|
+
{ id: 'b', role: 'assistant', name: 'advisor', content: 'charla' },
|
|
9
|
+
{ id: 'c', role: 'assistant', content: '2' },
|
|
10
|
+
{ id: 'd', role: 'user', content: '3' },
|
|
11
|
+
];
|
|
12
|
+
assert.deepEqual(liveHistory(history).map((m) => m.id), ['a', 'c', 'd']);
|
|
13
|
+
assert.deepEqual(liveHistory(history, 'a').map((m) => m.id), ['c', 'd']);
|
|
14
|
+
// Cursor inexistente: no perdemos contexto, devolvemos todo (sin asesor).
|
|
15
|
+
assert.deepEqual(liveHistory(history, 'zzz').map((m) => m.id), ['a', 'c', 'd']);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('planCompaction decide por umbral, tamano y contexto', () => {
|
|
19
|
+
const mk = (n, size = 4000) => Array.from({ length: n }, (_, i) => ({ id: `m${i}`, role: 'user', content: 'x'.repeat(size) }));
|
|
20
|
+
// Sin contexto conocido no compacta.
|
|
21
|
+
assert.equal(planCompaction(mk(20), { contextTokens: 0 }), null);
|
|
22
|
+
// Debajo del umbral no compacta.
|
|
23
|
+
assert.equal(planCompaction(mk(3), { contextTokens: 100000, threshold: 0.75, keepLast: 2 }), null);
|
|
24
|
+
// Muy pocos mensajes: no compacta aunque el contexto sea chico.
|
|
25
|
+
assert.equal(planCompaction(mk(4), { contextTokens: 100, threshold: 0.75, keepLast: 4 }), null);
|
|
26
|
+
|
|
27
|
+
const live = mk(12);
|
|
28
|
+
const plan = planCompaction(live, { contextTokens: 1000, threshold: 0.75, keepLast: 8 });
|
|
29
|
+
assert.ok(plan);
|
|
30
|
+
assert.equal(plan.toSummarize.length, 4);
|
|
31
|
+
assert.equal(plan.keep.length, 8);
|
|
32
|
+
assert.equal(plan.cursor, 'm3');
|
|
33
|
+
});
|
|
@@ -42,3 +42,17 @@ test('trimContext drops oldest groups but keeps the system and the tail', () =>
|
|
|
42
42
|
assert.ok(result.messages.length <= messages.length);
|
|
43
43
|
assert.ok(result.messages.length > 4);
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
test('trimContext protege los system iniciales (objetivo y resumen)', () => {
|
|
47
|
+
const messages = [
|
|
48
|
+
{ role: 'system', content: 'base' },
|
|
49
|
+
{ role: 'system', content: 'Objetivo: X' },
|
|
50
|
+
{ role: 'system', content: 'Resumen de la conversación anterior:\nviejo' },
|
|
51
|
+
{ role: 'user', content: 'u '.repeat(3000) },
|
|
52
|
+
{ role: 'assistant', content: 'a '.repeat(3000) },
|
|
53
|
+
];
|
|
54
|
+
const result = trimContext(messages, { maxTokens: 200, keepLast: 1 });
|
|
55
|
+
assert.equal(result.messages[0].content, 'base');
|
|
56
|
+
assert.equal(result.messages[1].content, 'Objetivo: X');
|
|
57
|
+
assert.match(result.messages[2].content, /Resumen de la conversación anterior/);
|
|
58
|
+
});
|
|
@@ -4,6 +4,8 @@ import { Store } from '../src/storage/sqlite.js';
|
|
|
4
4
|
import { composeProviderSettings } from '../src/config/providers.js';
|
|
5
5
|
import { toOllamaMessages } from '../src/providers/ollama.js';
|
|
6
6
|
import { providerFetch, normalizeModel } from '../src/providers/utils.js';
|
|
7
|
+
import { openaiCompatibleProvider } from '../src/providers/openaiCompatible.js';
|
|
8
|
+
import { applyPromptCache } from '../src/providers/cache.js';
|
|
7
9
|
import { isRetryableError } from '../src/providers/resilience.js';
|
|
8
10
|
|
|
9
11
|
test('normalizeModel reads the context window across provider shapes', () => {
|
|
@@ -105,3 +107,67 @@ test('providerFetch turns network failures into a readable, retryable error', as
|
|
|
105
107
|
globalThis.fetch = original;
|
|
106
108
|
}
|
|
107
109
|
});
|
|
110
|
+
|
|
111
|
+
test('openaiCompatible manda max_tokens y respeta maxTokensField', async () => {
|
|
112
|
+
const bodies = [];
|
|
113
|
+
const original = globalThis.fetch;
|
|
114
|
+
const sse = 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n';
|
|
115
|
+
globalThis.fetch = async (_url, init) => {
|
|
116
|
+
bodies.push(JSON.parse(init.body));
|
|
117
|
+
return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } });
|
|
118
|
+
};
|
|
119
|
+
const cfg = { baseUrl: 'https://x/v1', apiKey: 'k' };
|
|
120
|
+
const user = [{ role: 'user', content: 'u' }];
|
|
121
|
+
try {
|
|
122
|
+
await openaiCompatibleProvider.streamChat(cfg, { model: 'm', messages: user, maxOutputTokens: 100 });
|
|
123
|
+
const openai = { ...openaiCompatibleProvider, maxTokensField: 'max_completion_tokens' };
|
|
124
|
+
await openai.streamChat(cfg, { model: 'm', messages: user, maxOutputTokens: 200 });
|
|
125
|
+
await openaiCompatibleProvider.streamChat(cfg, { model: 'm', messages: user });
|
|
126
|
+
} finally {
|
|
127
|
+
globalThis.fetch = original;
|
|
128
|
+
}
|
|
129
|
+
assert.equal(bodies[0].max_tokens, 100);
|
|
130
|
+
assert.equal(bodies[0].max_completion_tokens, undefined);
|
|
131
|
+
assert.equal(bodies[1].max_completion_tokens, 200);
|
|
132
|
+
assert.equal(bodies[2].max_tokens, undefined);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('applyPromptCache: anthropic marca el system, openai agrega la clave', () => {
|
|
136
|
+
const anthropic = { messages: [{ role: 'system', content: 'sys' }, { role: 'user', content: 'u' }] };
|
|
137
|
+
applyPromptCache(anthropic, { mode: 'anthropic' });
|
|
138
|
+
assert.deepEqual(anthropic.messages[0].content, [
|
|
139
|
+
{ type: 'text', text: 'sys', cache_control: { type: 'ephemeral' } },
|
|
140
|
+
]);
|
|
141
|
+
assert.equal(anthropic.messages[1].content, 'u');
|
|
142
|
+
|
|
143
|
+
const openai = { messages: [{ role: 'system', content: 'sys' }], tools: [] };
|
|
144
|
+
applyPromptCache(openai, { mode: 'openai' });
|
|
145
|
+
assert.equal(typeof openai.prompt_cache_key, 'string');
|
|
146
|
+
assert.equal(openai.prompt_cache_key.length, 32);
|
|
147
|
+
|
|
148
|
+
const none = { messages: [{ role: 'system', content: 'sys' }] };
|
|
149
|
+
applyPromptCache(none, { mode: 'none' });
|
|
150
|
+
assert.equal(none.messages[0].content, 'sys');
|
|
151
|
+
assert.equal(none.prompt_cache_key, undefined);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('openaiCompatible aplica prompt caching solo si está activado', async () => {
|
|
155
|
+
const bodies = [];
|
|
156
|
+
const original = globalThis.fetch;
|
|
157
|
+
const sse = 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n';
|
|
158
|
+
globalThis.fetch = async (_url, init) => {
|
|
159
|
+
bodies.push(JSON.parse(init.body));
|
|
160
|
+
return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } });
|
|
161
|
+
};
|
|
162
|
+
const cfg = { baseUrl: 'https://x/v1', apiKey: 'k' };
|
|
163
|
+
const user = [{ role: 'user', content: 'u' }];
|
|
164
|
+
const openai = { ...openaiCompatibleProvider, caching: 'openai' };
|
|
165
|
+
try {
|
|
166
|
+
await openai.streamChat(cfg, { model: 'm', messages: user, promptCaching: true });
|
|
167
|
+
await openai.streamChat(cfg, { model: 'm', messages: user, promptCaching: false });
|
|
168
|
+
} finally {
|
|
169
|
+
globalThis.fetch = original;
|
|
170
|
+
}
|
|
171
|
+
assert.equal(typeof bodies[0].prompt_cache_key, 'string');
|
|
172
|
+
assert.equal(bodies[1].prompt_cache_key, undefined);
|
|
173
|
+
});
|
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { parseSkillMarkdown, loadSkills, mergeSkills, buildSkillsPrompt } from '../src/skills/index.js';
|
|
6
|
+
import { parseSkillMarkdown, loadSkills, mergeSkills, buildSkillsPrompt, buildSkillsIndex } from '../src/skills/index.js';
|
|
7
7
|
import { parseSkillRef } from '../src/skills/remote.js';
|
|
8
8
|
|
|
9
9
|
test('parseSkillMarkdown reads frontmatter and body', () => {
|
|
@@ -67,3 +67,12 @@ test('buildSkillsPrompt lists enabled skills and is empty without skills', () =>
|
|
|
67
67
|
assert.match(text, /Demo/);
|
|
68
68
|
assert.match(text, /Hacé X\./);
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
test('buildSkillsIndex lists id + when without the body (progressive disclosure)', () => {
|
|
72
|
+
assert.equal(buildSkillsIndex([]), '');
|
|
73
|
+
const text = buildSkillsIndex([{ id: 'demo', name: 'Demo', when: 'usar en PRs', body: 'SECRETO_BODY' }]);
|
|
74
|
+
assert.match(text, /demo/);
|
|
75
|
+
assert.match(text, /usar en PRs/);
|
|
76
|
+
assert.match(text, /skill\(\{ name \}\)/);
|
|
77
|
+
assert.doesNotMatch(text, /SECRETO_BODY/);
|
|
78
|
+
});
|
|
@@ -89,3 +89,23 @@ test('usage is null when the provider does not report it', async () => {
|
|
|
89
89
|
const res = await readOpenAISSE(new Response(sse));
|
|
90
90
|
assert.equal(res.usage, null);
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
test('readOpenAISSE corta el stream cuando la señal aborta', async () => {
|
|
94
|
+
const encoder = new TextEncoder();
|
|
95
|
+
// Un body que emite un frame y nunca cierra: el unico corte posible es abort.
|
|
96
|
+
const stream = new ReadableStream({
|
|
97
|
+
start(controller) {
|
|
98
|
+
controller.enqueue(encoder.encode('data: {"choices":[{"delta":{"content":"hola"}}]}\n\n'));
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
const ac = new AbortController();
|
|
102
|
+
const promise = readOpenAISSE(new Response(stream), { signal: ac.signal });
|
|
103
|
+
setTimeout(() => ac.abort(), 10);
|
|
104
|
+
await assert.rejects(promise, (error) => error.name === 'AbortError');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('readOpenAISSE con la señal ya abortada no lee nada', async () => {
|
|
108
|
+
const ac = new AbortController();
|
|
109
|
+
ac.abort();
|
|
110
|
+
await assert.rejects(readOpenAISSE(new Response('data: [DONE]\n'), { signal: ac.signal }), (error) => error.name === 'AbortError');
|
|
111
|
+
});
|
|
@@ -158,6 +158,24 @@ test('bash runs inside the workspace root', async () => {
|
|
|
158
158
|
assert.match(out, /\[exit 0\]/);
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
test('bash se cancela cuando aborta la señal del run', async () => {
|
|
162
|
+
const root = await makeWorkspace();
|
|
163
|
+
const ac = new AbortController();
|
|
164
|
+
const reg = createToolRegistry({ root, permissions: { bash: 'allow' }, signal: ac.signal });
|
|
165
|
+
const promise = reg.execute('bash', { command: 'node -e "setTimeout(() => {}, 5000)"', timeout: 30 });
|
|
166
|
+
setTimeout(() => ac.abort(), 150);
|
|
167
|
+
const out = await promise;
|
|
168
|
+
assert.match(out, /cancelado/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('bash con la señal ya abortada no arranca el proceso', async () => {
|
|
172
|
+
const root = await makeWorkspace();
|
|
173
|
+
const ac = new AbortController();
|
|
174
|
+
ac.abort();
|
|
175
|
+
const reg = createToolRegistry({ root, permissions: { bash: 'allow' }, signal: ac.signal });
|
|
176
|
+
assert.match(await reg.execute('bash', { command: 'echo hola' }), /cancelado/);
|
|
177
|
+
});
|
|
178
|
+
|
|
161
179
|
test('la tool speak valida el texto y la recorta', async () => {
|
|
162
180
|
const root = await makeWorkspace();
|
|
163
181
|
const reg = createToolRegistry({ root });
|