@danieltmn/tamnora 0.11.3 → 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 +24 -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 +20 -4
- package/harness/packages/server/public/index.html +4 -1
- 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/scope.test.js +68 -0
- package/harness/packages/server/test/server.test.js +130 -49
- package/package.json +1 -1
|
@@ -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 });
|
|
@@ -431,6 +431,7 @@ const providerSelect = $('#provider');
|
|
|
431
431
|
const modelSelect = $('#model');
|
|
432
432
|
const modeSelect = $('#mode');
|
|
433
433
|
const yesInput = $('#yes');
|
|
434
|
+
const strictInput = $('#strict-scope');
|
|
434
435
|
|
|
435
436
|
async function loadModes() {
|
|
436
437
|
const data = await api('/api/modes').catch(() => ({ modes: [] }));
|
|
@@ -445,6 +446,7 @@ async function loadModes() {
|
|
|
445
446
|
const settings = await api('/api/settings').catch(() => null);
|
|
446
447
|
const active = settings?.settings?.mode || 'agent';
|
|
447
448
|
if ([...modeSelect.options].some((o) => o.value === active)) modeSelect.value = active;
|
|
449
|
+
if (strictInput) strictInput.checked = settings?.settings?.strictScope === true;
|
|
448
450
|
defaultCwdCache = settings?.settings?.defaultCwd || '';
|
|
449
451
|
if (!defaultCwdCache) {
|
|
450
452
|
// Migración: la carpeta predeterminada vivía en localStorage; la subimos al
|
|
@@ -549,6 +551,9 @@ document.addEventListener('keydown', (event) => {
|
|
|
549
551
|
});
|
|
550
552
|
modelPill?.addEventListener('click', () => openSettingsModal());
|
|
551
553
|
yesInput?.addEventListener('change', renderModePill);
|
|
554
|
+
strictInput?.addEventListener('change', () =>
|
|
555
|
+
api('/api/settings', { method: 'PUT', body: { strictScope: strictInput.checked } }).catch(() => null)
|
|
556
|
+
);
|
|
552
557
|
|
|
553
558
|
function updateStatus(extra = {}) {
|
|
554
559
|
renderModePill();
|
|
@@ -1056,6 +1061,7 @@ const ACTIVITY = {
|
|
|
1056
1061
|
thinking: { cls: 'loader-thinking', label: 'pensando…' },
|
|
1057
1062
|
working: { cls: 'loader-working', label: 'trabajando…' },
|
|
1058
1063
|
reconnecting: { cls: 'loader-working', label: 'reconectando…' },
|
|
1064
|
+
stopping: { cls: 'loader-working', label: 'deteniendo…' },
|
|
1059
1065
|
};
|
|
1060
1066
|
|
|
1061
1067
|
const footerActivityEl = $('#sb-activity');
|
|
@@ -2526,11 +2532,21 @@ async function sendMessage() {
|
|
|
2526
2532
|
}
|
|
2527
2533
|
|
|
2528
2534
|
if (stopBtn) {
|
|
2529
|
-
stopBtn.addEventListener('click', () => {
|
|
2535
|
+
stopBtn.addEventListener('click', async () => {
|
|
2530
2536
|
const id = state.sessionId;
|
|
2531
|
-
if (id
|
|
2532
|
-
|
|
2533
|
-
|
|
2537
|
+
if (!id || stopBtn.disabled) return;
|
|
2538
|
+
stopBtn.disabled = true;
|
|
2539
|
+
renderFooterActivity('stopping');
|
|
2540
|
+
// Abortamos el stream local (corta el seguimiento ya) y pedimos al server que
|
|
2541
|
+
// aborte el run: sin esto el run es durable y seguiria generando.
|
|
2542
|
+
controllers.get(id)?.abort();
|
|
2543
|
+
try {
|
|
2544
|
+
await api(`/api/sessions/${id}/stop`, { method: 'POST' });
|
|
2545
|
+
} catch (error) {
|
|
2546
|
+
toast(`No se pudo detener el run: ${error.message}`);
|
|
2547
|
+
} finally {
|
|
2548
|
+
stopBtn.disabled = false;
|
|
2549
|
+
}
|
|
2534
2550
|
});
|
|
2535
2551
|
}
|
|
2536
2552
|
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
</head>
|
|
38
38
|
<body>
|
|
39
39
|
<header class="topbar">
|
|
40
|
-
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.
|
|
40
|
+
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.12.0</span></div>
|
|
41
41
|
<nav class="tabs">
|
|
42
42
|
<button class="tab active" data-tab="chat">chat</button>
|
|
43
43
|
<button class="tab" data-tab="providers">proveedores</button>
|
|
@@ -486,6 +486,9 @@
|
|
|
486
486
|
<label class="check" title="aprobar automáticamente tools con permiso ask">
|
|
487
487
|
<input id="yes" type="checkbox" /> Aprobar automáticamente las tools (auto)
|
|
488
488
|
</label>
|
|
489
|
+
<label class="check" title="el agente hace exactamente lo pedido, sin mejoras ni cambios fuera del pedido">
|
|
490
|
+
<input id="strict-scope" type="checkbox" /> Hacer solo lo pedido (estricto)
|
|
491
|
+
</label>
|
|
489
492
|
</div>
|
|
490
493
|
</div>
|
|
491
494
|
<div class="row">
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// subagentes). La creacion de tools y el onEvent de cada canal siguen siendo
|
|
3
3
|
// propios (los canales de salida son distintos); lo combinable vive aca.
|
|
4
4
|
|
|
5
|
-
import { summarizeToolCall, previewToolResult } from './core.js';
|
|
5
|
+
import { summarizeToolCall, previewToolResult, liveHistory } from './core.js';
|
|
6
6
|
|
|
7
7
|
const FAILED_RE = /^Error|^Permiso denegado/i;
|
|
8
8
|
|
|
@@ -21,16 +21,15 @@ export function toolCallPayload(event) {
|
|
|
21
21
|
* `approve` es el callback de aprobacion de las tools de administraación.
|
|
22
22
|
*/
|
|
23
23
|
export function combineToolset({ local, admin = null, tasks = null, exposeAdmin = false, approve = null }) {
|
|
24
|
+
const adminAllowed = (name) => Boolean(admin?.handles(name)) && (exposeAdmin || admin.readOnly?.has(name));
|
|
24
25
|
const definitions = [
|
|
25
26
|
...local.definitions,
|
|
26
|
-
...(
|
|
27
|
+
...(admin ? admin.definitions.filter((d) => adminAllowed(d.function.name)) : []),
|
|
27
28
|
...(tasks ? tasks.definitions : []),
|
|
28
29
|
];
|
|
29
30
|
const execute = (name, args, context) => {
|
|
30
31
|
if (tasks?.handles(name)) return tasks.execute(name, args);
|
|
31
|
-
if (
|
|
32
|
-
return admin.execute(name, args, approve ? { approve } : undefined);
|
|
33
|
-
}
|
|
32
|
+
if (adminAllowed(name)) return admin.execute(name, args, approve ? { approve } : undefined);
|
|
34
33
|
return local.execute(name, args, context);
|
|
35
34
|
};
|
|
36
35
|
return { definitions, execute };
|
|
@@ -67,12 +66,13 @@ export async function composeAgentMessages({ ctx, cwd, mode, history = [], userM
|
|
|
67
66
|
const basePrompt = await ctx.systemPromptFor(cwd);
|
|
68
67
|
const goal = sessionRecord?.meta?.goal;
|
|
69
68
|
const summary = sessionRecord?.meta?.summary;
|
|
69
|
+
// Historial vigente: sin el asesor y sin lo ya resumido (si hay cursor).
|
|
70
|
+
const live = liveHistory(history, sessionRecord?.meta?.summaryCursor ?? null);
|
|
70
71
|
return [
|
|
71
72
|
{ role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
|
|
72
73
|
...(goal ? [{ role: 'system', content: `Objetivo de la sesión: ${goal}` }] : []),
|
|
73
74
|
...(summary ? [{ role: 'system', content: `Resumen de la conversación anterior:\n${summary}` }] : []),
|
|
74
|
-
|
|
75
|
-
...history.filter((message) => message.name !== 'advisor'),
|
|
75
|
+
...live,
|
|
76
76
|
userMessage,
|
|
77
77
|
];
|
|
78
78
|
}
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
normalizeMcpConfig,
|
|
14
14
|
homeDir,
|
|
15
15
|
} from './core.js';
|
|
16
|
-
import { SKILL_ID_PATTERN, skillsView, writeSkill, setSkillEnabled } from './skills.js';
|
|
16
|
+
import { SKILL_ID_PATTERN, skillsView, writeSkill, setSkillEnabled, findSkill } from './skills.js';
|
|
17
17
|
|
|
18
18
|
function sanitizeId(value) {
|
|
19
19
|
return String(value ?? '')
|
|
@@ -51,6 +51,12 @@ const DEFINITIONS = [
|
|
|
51
51
|
),
|
|
52
52
|
definition('remove_mcp', 'Elimina un servidor MCP guardado desde la consola.', { id: { type: 'string' } }, ['id']),
|
|
53
53
|
definition('list_skills', 'Lista las skills instaladas y si están habilitadas.', {}),
|
|
54
|
+
definition(
|
|
55
|
+
'skill',
|
|
56
|
+
'Carga el contenido completo de una skill por su id. Usala cuando una skill del índice aplique a la tarea.',
|
|
57
|
+
{ name: { type: 'string', description: 'id de la skill (ver el índice de skills)' } },
|
|
58
|
+
['name']
|
|
59
|
+
),
|
|
54
60
|
definition(
|
|
55
61
|
'install_skill',
|
|
56
62
|
'Crea o actualiza una skill en la casa. Pasá "content" (markdown, opcionalmente con frontmatter) o "url" para descargarla.',
|
|
@@ -88,7 +94,7 @@ function decisionFor(ctx, name) {
|
|
|
88
94
|
const perms = ctx.config.permissions ?? {};
|
|
89
95
|
const value = perms[name];
|
|
90
96
|
if (value === 'allow' || value === 'ask' || value === 'deny') return value;
|
|
91
|
-
return name.startsWith('list_') ? 'allow' : 'ask';
|
|
97
|
+
return name.startsWith('list_') || name === 'skill' ? 'allow' : 'ask';
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
/**
|
|
@@ -197,6 +203,13 @@ export function createAgentTools(ctx) {
|
|
|
197
203
|
case 'list_skills':
|
|
198
204
|
return JSON.stringify(skillsView(ctx).map(({ id, name, enabled, description }) => ({ id, name, enabled, description })), null, 2);
|
|
199
205
|
|
|
206
|
+
case 'skill': {
|
|
207
|
+
const id = String(args.name ?? args.id ?? '').trim();
|
|
208
|
+
const skill = findSkill(ctx, id);
|
|
209
|
+
if (!skill) return `No existe la skill "${id}".`;
|
|
210
|
+
return `# ${skill.name || skill.id}\n\n${skill.body}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
200
213
|
case 'install_skill': {
|
|
201
214
|
const ref = parseSkillRef(args.ref ?? args.url ?? '');
|
|
202
215
|
if (ref && (args.ref || (args.url && !/\.md(\?|$)/i.test(String(args.url))))) {
|
|
@@ -240,5 +253,7 @@ export function createAgentTools(ctx) {
|
|
|
240
253
|
}
|
|
241
254
|
}
|
|
242
255
|
|
|
243
|
-
|
|
256
|
+
// Tools de admin que no modifican nada (lístar/leer): disponibles en todo modo.
|
|
257
|
+
const readOnly = new Set(['list_mcp', 'list_skills', 'skill']);
|
|
258
|
+
return { definitions: DEFINITIONS, handles, execute, readOnly };
|
|
244
259
|
}
|
|
@@ -48,6 +48,13 @@ export function createApprovalBroker({ timeoutMs = 300000 } = {}) {
|
|
|
48
48
|
for (const id of ids) settle(id, false);
|
|
49
49
|
},
|
|
50
50
|
|
|
51
|
+
/** Deny every pending request of a session (used when the user stops the run). */
|
|
52
|
+
cancelForSession(sessionId) {
|
|
53
|
+
for (const [id, entry] of pending) {
|
|
54
|
+
if (entry.sessionId === sessionId) settle(id, false);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
51
58
|
/** Deny everything still pending (used on shutdown). */
|
|
52
59
|
cancelAll() {
|
|
53
60
|
for (const id of [...pending.keys()]) settle(id, false);
|