@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +7 -1
  3. package/harness/packages/cli/src/commands/repl.js +18 -5
  4. package/harness/packages/cli/src/commands/run.js +11 -2
  5. package/harness/packages/cli/src/commands/tui.js +13 -3
  6. package/harness/packages/cli/src/lib/runtime.js +2 -0
  7. package/harness/packages/cli/src/tui/app.js +22 -0
  8. package/harness/packages/core/src/agent/compact.js +38 -0
  9. package/harness/packages/core/src/agent/context.js +13 -8
  10. package/harness/packages/core/src/agent/runAgent.js +15 -2
  11. package/harness/packages/core/src/agent/scope.js +15 -0
  12. package/harness/packages/core/src/config/index.js +4 -0
  13. package/harness/packages/core/src/index.js +10 -1
  14. package/harness/packages/core/src/providers/cache.js +29 -0
  15. package/harness/packages/core/src/providers/ollama.js +3 -2
  16. package/harness/packages/core/src/providers/openaiCompatible.js +9 -2
  17. package/harness/packages/core/src/providers/openrouter.js +6 -1
  18. package/harness/packages/core/src/providers/presets.js +7 -4
  19. package/harness/packages/core/src/providers/stream.js +45 -4
  20. package/harness/packages/core/src/skills/index.js +21 -0
  21. package/harness/packages/core/src/tools/executors.js +21 -13
  22. package/harness/packages/core/src/tools/index.js +2 -2
  23. package/harness/packages/core/src/tools/registry.js +3 -2
  24. package/harness/packages/core/test/agent.test.js +38 -0
  25. package/harness/packages/core/test/compact.test.js +33 -0
  26. package/harness/packages/core/test/context.test.js +14 -0
  27. package/harness/packages/core/test/providers.test.js +66 -0
  28. package/harness/packages/core/test/skills.test.js +10 -1
  29. package/harness/packages/core/test/stream.test.js +20 -0
  30. package/harness/packages/core/test/tools.test.js +18 -0
  31. package/harness/packages/server/public/app.js +20 -4
  32. package/harness/packages/server/public/index.html +4 -1
  33. package/harness/packages/server/src/lib/agentRun.js +7 -7
  34. package/harness/packages/server/src/lib/agentTools.js +18 -3
  35. package/harness/packages/server/src/lib/approvals.js +7 -0
  36. package/harness/packages/server/src/lib/channels/telegram.js +9 -1
  37. package/harness/packages/server/src/lib/compact.js +77 -0
  38. package/harness/packages/server/src/lib/prompt.js +4 -1
  39. package/harness/packages/server/src/lib/skills.js +12 -1
  40. package/harness/packages/server/src/lib/tasks.js +2 -1
  41. package/harness/packages/server/src/routes/chat.js +21 -2
  42. package/harness/packages/server/src/routes/providers.js +4 -1
  43. package/harness/packages/server/src/routes/sessions.js +4 -50
  44. package/harness/packages/server/src/server.js +8 -2
  45. package/harness/packages/server/test/scope.test.js +68 -0
  46. package/harness/packages/server/test/server.test.js +130 -49
  47. 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.read();
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.read();
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 timer = setTimeout(() => {
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
- resolve(`${output}\n[timeout tras ${seconds}s]\n[exit -1]`);
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
- resolve(`${output}${truncated}\n[exit ${code}]`);
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) api(`/api/sessions/${id}/stop`, { method: 'POST' }).catch(() => {});
2532
- const controller = controllers.get(id);
2533
- if (controller) controller.abort();
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.11.3</span></div>
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
- ...(exposeAdmin && admin ? admin.definitions : []),
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 (exposeAdmin && admin?.handles(name)) {
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
- // El asesor no contamina el contexto del modelo: es una capa de comentario.
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
- return { definitions: DEFINITIONS, handles, execute };
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);