@danieltmn/tamnora 0.25.0 → 0.25.1

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 (45) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/harness/packages/cli/src/commands/repl.js +18 -2
  3. package/harness/packages/cli/src/commands/run.js +5 -1
  4. package/harness/packages/cli/src/commands/tui.js +5 -1
  5. package/harness/packages/cli/src/lib/runtime.js +2 -0
  6. package/harness/packages/cli/src/tui/app.js +22 -2
  7. package/harness/packages/cli/src/tui/layout.js +1 -1
  8. package/harness/packages/core/src/agent/runAgent.js +5 -2
  9. package/harness/packages/core/src/agents/index.js +141 -0
  10. package/harness/packages/core/src/config/index.js +1 -0
  11. package/harness/packages/core/src/index.js +10 -0
  12. package/harness/packages/core/src/providers/index.js +3 -1
  13. package/harness/packages/core/src/providers/ollama.js +4 -1
  14. package/harness/packages/core/src/providers/openaiCompatible.js +6 -1
  15. package/harness/packages/core/src/providers/openrouter.js +5 -1
  16. package/harness/packages/core/src/providers/presets.js +4 -3
  17. package/harness/packages/core/src/providers/reasoning.js +58 -0
  18. package/harness/packages/core/test/agents.test.js +111 -0
  19. package/harness/packages/core/test/reasoning.test.js +43 -0
  20. package/harness/packages/server/public/app.js +37 -0
  21. package/harness/packages/server/public/chat.js +1 -0
  22. package/harness/packages/server/public/chrome.js +94 -1
  23. package/harness/packages/server/public/core.js +1 -1
  24. package/harness/packages/server/public/icons (1).zip +0 -0
  25. package/harness/packages/server/public/index.html +90 -1
  26. package/harness/packages/server/public/sessions.js +1 -0
  27. package/harness/packages/server/public/settings.js +167 -0
  28. package/harness/packages/server/public/settingsNav.js +1 -0
  29. package/harness/packages/server/src/lib/agents.js +74 -0
  30. package/harness/packages/server/src/lib/channels/telegram/run.js +1 -0
  31. package/harness/packages/server/src/lib/prompt.js +11 -3
  32. package/harness/packages/server/src/lib/scheduler.js +4 -0
  33. package/harness/packages/server/src/lib/sessionView.js +1 -0
  34. package/harness/packages/server/src/lib/skills.js +13 -0
  35. package/harness/packages/server/src/lib/tasks.js +71 -23
  36. package/harness/packages/server/src/routes/agents.js +88 -0
  37. package/harness/packages/server/src/routes/chat.js +9 -1
  38. package/harness/packages/server/src/routes/providers.js +7 -0
  39. package/harness/packages/server/src/routes/sessions.js +3 -1
  40. package/harness/packages/server/src/server.js +7 -0
  41. package/harness/packages/server/test/agents.test.js +194 -0
  42. package/harness/packages/server/test/consoleTabs.test.js +2 -2
  43. package/harness/packages/server/test/settingsNav.test.js +2 -2
  44. package/harness/tamnora.example.json +1 -0
  45. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.25.1] - 2026-11-04
9
+
10
+ ### Cambios
11
+
12
+ - feat: esfuerzo de razonamiento y subagentes en archivos
13
+
8
14
  ## [0.25.0] - 2026-11-01
9
15
 
10
16
  ### Cambios
@@ -7,6 +7,7 @@ import {
7
7
  createTools,
8
8
  buildMcpRegistry,
9
9
  scopeDirective,
10
+ normalizeEffort,
10
11
  } from '../lib/core.js';
11
12
  import { parse, databasePath, num } from '../lib/util.js';
12
13
  import { buildSystemPrompt, runTurn, createPrinter, resolveCliProvider, mcpSettings, mcpIndexBlock } from '../lib/runtime.js';
@@ -21,6 +22,7 @@ const OPTIONS = {
21
22
  'no-tools': { type: 'boolean' },
22
23
  strict: { type: 'boolean' },
23
24
  'no-strict': { type: 'boolean' },
25
+ effort: { type: 'string' },
24
26
  yes: { type: 'boolean', short: 'y' },
25
27
  };
26
28
 
@@ -35,6 +37,7 @@ export async function replCommand(argv) {
35
37
  const maxRounds = num(values['max-steps'], config.maxRounds);
36
38
  const store = new Store(databasePath(cwd));
37
39
  let strict = values.strict ? true : values['no-strict'] ? false : store.getSetting('strictScope') === true;
40
+ let effort = normalizeEffort(values.effort ?? store.getSetting('activeEffort') ?? config.effort);
38
41
  const buildPrompt = () =>
39
42
  buildSystemPrompt(instructions, [systemExtra, mcpBlock, scopeDirective(strict)].filter(Boolean).join('\n\n'));
40
43
  const resolved = resolveCliProvider(store, config, { provider: values.provider, model: values.model });
@@ -97,7 +100,7 @@ export async function replCommand(argv) {
97
100
  mcpPinned,
98
101
  });
99
102
 
100
- process.stdout.write(color.dim(`Tamnora REPL · ${providerId}/${model}\n`));
103
+ process.stdout.write(color.dim(`Tamnora REPL · ${providerId}/${model}${effort ? ` · esfuerzo ${effort}` : ''}\n`));
101
104
  process.stdout.write(color.dim(`workspace ${cwd}\n`));
102
105
  process.stdout.write(color.dim(`session ${sessionId} · /help para comandos · Ctrl+C para salir\n\n`));
103
106
  rl.prompt();
@@ -120,7 +123,7 @@ export async function replCommand(argv) {
120
123
  } else if (cmd === 'help') {
121
124
  process.stdout.write(
122
125
  `${color.dim(
123
- '/model <id> /provider <id> /system <texto> /strict /clear /session /exit'
126
+ '/model <id> /provider <id> /system <texto> /strict /esfuerzo <nivel> /clear /session /exit'
124
127
  )}\n`
125
128
  );
126
129
  } else if (cmd === 'model') {
@@ -137,6 +140,18 @@ export async function replCommand(argv) {
137
140
  else strict = !strict;
138
141
  store.setSetting('strictScope', strict);
139
142
  process.stdout.write(color.dim(`solo lo pedido: ${strict ? 'activado' : 'desactivado'}\n`));
143
+ } else if (cmd === 'effort' || cmd === 'esfuerzo') {
144
+ if (arg) {
145
+ const level = normalizeEffort(arg);
146
+ if (!level && !/^(auto|default)$/i.test(arg)) {
147
+ process.stdout.write(color.red('nivel inválido: off | low | medium | high | max | auto\n'));
148
+ rl.prompt();
149
+ return;
150
+ }
151
+ effort = level;
152
+ store.setSetting('activeEffort', level || null);
153
+ }
154
+ process.stdout.write(color.dim(`esfuerzo: ${effort || 'auto'}\n`));
140
155
  } else if (cmd === 'clear') {
141
156
  history = [];
142
157
  process.stdout.write(color.dim('historial limpiado\n'));
@@ -167,6 +182,7 @@ export async function replCommand(argv) {
167
182
  maxDurationMs: config.maxDurationMs,
168
183
  maxOutputTokens: config.maxOutputTokens,
169
184
  promptCaching: config.promptCaching === true,
185
+ reasoningEffort: effort,
170
186
  tools: toolDefs,
171
187
  execute,
172
188
  onEvent: printer.onEvent,
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { loadConfig, loadInstructions, Store, createTools, buildMcpRegistry, scopeDirective } from '../lib/core.js';
2
+ import { loadConfig, loadInstructions, Store, createTools, buildMcpRegistry, scopeDirective, normalizeEffort } from '../lib/core.js';
3
3
  import { parse, databasePath, num } from '../lib/util.js';
4
4
  import { buildSystemPrompt, runTurn, createPrinter, resolveCliProvider, mcpSettings, mcpIndexBlock } from '../lib/runtime.js';
5
5
  import { color } from '../ui/color.js';
@@ -17,6 +17,7 @@ const OPTIONS = {
17
17
  'no-tools': { type: 'boolean' },
18
18
  strict: { type: 'boolean' },
19
19
  'no-strict': { type: 'boolean' },
20
+ effort: { type: 'string' },
20
21
  yes: { type: 'boolean', short: 'y' },
21
22
  help: { type: 'boolean', short: 'h' },
22
23
  };
@@ -101,6 +102,7 @@ export async function runCommand(argv) {
101
102
  process.once('SIGINT', onSigint);
102
103
 
103
104
  const strict = values.strict ? true : values['no-strict'] ? false : store.getSetting('strictScope') === true;
105
+ const effort = normalizeEffort(values.effort ?? store.getSetting('activeEffort') ?? config.effort);
104
106
 
105
107
  try {
106
108
  const { result, newMessages } = await runTurn({
@@ -120,6 +122,7 @@ export async function runCommand(argv) {
120
122
  maxDurationMs: config.maxDurationMs,
121
123
  maxOutputTokens: config.maxOutputTokens,
122
124
  promptCaching: config.promptCaching === true,
125
+ reasoningEffort: effort,
123
126
  signal: controller.signal,
124
127
  tools: toolDefs,
125
128
  execute,
@@ -176,6 +179,7 @@ Opciones:
176
179
  --tools <lista> Tools habilitadas (coma), ej. read,list,grep
177
180
  --no-tools Deshabilitar todas las tools
178
181
  --strict Hacer solo lo pedido (estricto) --no-strict
182
+ --effort <nivel> Esfuerzo de razonamiento: off | low | medium | high | max
179
183
  -y, --yes Aprobar automáticamente los permisos "ask"
180
184
  --json Salida JSON (sin streaming)`);
181
185
  }
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { loadConfig, loadInstructions, Store, buildMcpRegistry, resolveMode, scopeDirective } from '../lib/core.js';
2
+ import { loadConfig, loadInstructions, Store, buildMcpRegistry, resolveMode, scopeDirective, normalizeEffort } from '../lib/core.js';
3
3
  import { parse, databasePath, num } from '../lib/util.js';
4
4
  import { buildSystemPrompt, resolveCliProvider, mcpSettings, mcpIndexBlock } from '../lib/runtime.js';
5
5
  import { runTui } from '../tui/app.js';
@@ -16,6 +16,7 @@ const OPTIONS = {
16
16
  'no-tools': { type: 'boolean' },
17
17
  strict: { type: 'boolean' },
18
18
  'no-strict': { type: 'boolean' },
19
+ effort: { type: 'string' },
19
20
  yes: { type: 'boolean', short: 'y' },
20
21
  help: { type: 'boolean', short: 'h' },
21
22
  };
@@ -68,6 +69,7 @@ export async function tuiCommand(argv) {
68
69
 
69
70
  const mode = resolveMode(values.mode || store.getSetting('activeMode') || 'agent');
70
71
  let strict = values.strict ? true : values['no-strict'] ? false : store.getSetting('strictScope') === true;
72
+ const effort = normalizeEffort(values.effort ?? store.getSetting('activeEffort') ?? config.effort);
71
73
  const enabledFor = (m) =>
72
74
  m.tools ? (baseEnabled ? baseEnabled.filter((tool) => m.tools.includes(tool)) : m.tools) : baseEnabled;
73
75
  const promptFor = (m) =>
@@ -102,6 +104,7 @@ export async function tuiCommand(argv) {
102
104
  maxDurationMs: config.maxDurationMs,
103
105
  maxOutputTokens: config.maxOutputTokens,
104
106
  promptCaching: config.promptCaching === true,
107
+ reasoningEffort: effort,
105
108
  toolOptions: {
106
109
  root: cwd,
107
110
  enabled: enabledFor(mode),
@@ -133,5 +136,6 @@ Opciones:
133
136
  --max-steps <n> Techo de rondas de herramientas (0 = sin límite, default)
134
137
  --tools <lista> Tools habilitadas (coma) --no-tools
135
138
  --strict Hacer solo lo pedido (estricto) --no-strict
139
+ --effort <nivel> Esfuerzo de razonamiento: off | low | medium | high | max
136
140
  -y, --yes Auto-aprobar tools con permiso "ask"`);
137
141
  }
@@ -78,6 +78,7 @@ export async function runTurn({
78
78
  maxDurationMs,
79
79
  maxOutputTokens,
80
80
  promptCaching,
81
+ reasoningEffort,
81
82
  stallWarnMs,
82
83
  signal,
83
84
  execute,
@@ -106,6 +107,7 @@ export async function runTurn({
106
107
  maxDurationMs,
107
108
  maxOutputTokens,
108
109
  promptCaching,
110
+ reasoningEffort,
109
111
  stallWarnMs,
110
112
  signal,
111
113
  onEvent,
@@ -2,7 +2,7 @@
2
2
  // Alt-screen + raw input + incremental rendering; reuses the harness engine.
3
3
 
4
4
  import { readFileSync } from 'node:fs';
5
- import { createTools, diffRows, resolveInRoot } from '../lib/core.js';
5
+ import { createTools, diffRows, resolveInRoot, normalizeEffort } from '../lib/core.js';
6
6
  import { runTurn } from '../lib/runtime.js';
7
7
  import { parseKeys } from './keys.js';
8
8
  import { render } from './layout.js';
@@ -25,6 +25,7 @@ export function runTui({
25
25
  maxDurationMs,
26
26
  maxOutputTokens,
27
27
  promptCaching,
28
+ reasoningEffort,
28
29
  toolOptions,
29
30
  store,
30
31
  sessionId,
@@ -37,8 +38,9 @@ export function runTui({
37
38
  let activeMode = readMode(mode);
38
39
  let modePrompt = activeMode.systemPrompt ?? systemPrompt;
39
40
  let modeEnabled = activeMode.enabled;
41
+ let effort = reasoningEffort || '';
40
42
  const state = {
41
- header: { provider: providerId, model, cwd: toolOptions.root },
43
+ header: { provider: providerId, model, cwd: toolOptions.root, effort },
42
44
  messages: [],
43
45
  input: '',
44
46
  inputCursor: 0,
@@ -176,6 +178,23 @@ export function runTui({
176
178
  toggleStrict(/^(on|si|sí)$/i.test(strictArg[1]));
177
179
  return;
178
180
  }
181
+ const effortArg = text.match(/^\/(?:esfuerzo|effort)(?:\s+(\S+))?$/i);
182
+ if (effortArg) {
183
+ if (effortArg[1]) {
184
+ const next = normalizeEffort(effortArg[1]);
185
+ if (!next && !/^(auto|default)$/i.test(effortArg[1])) {
186
+ addMessage('assistant', 'Nivel inválido: off | low | medium | high | max | auto.');
187
+ draw();
188
+ return;
189
+ }
190
+ effort = next;
191
+ state.header.effort = effort;
192
+ store.setSetting('activeEffort', effort || null);
193
+ }
194
+ addMessage('assistant', `Esfuerzo: ${effort || 'auto'}.`);
195
+ draw();
196
+ return;
197
+ }
179
198
  if (text === '/limpiar' || text === '/clear') {
180
199
  state.input = '';
181
200
  state.messages = [];
@@ -209,6 +228,7 @@ export function runTui({
209
228
  maxDurationMs,
210
229
  maxOutputTokens,
211
230
  promptCaching,
231
+ reasoningEffort: effort,
212
232
  signal: controller.signal,
213
233
  tools: toolset.definitions,
214
234
  execute: toolset.execute,
@@ -126,7 +126,7 @@ function buildBody(state, width) {
126
126
 
127
127
  function headerText(state, width) {
128
128
  const left = ` ❯ tamnora`;
129
- const middle = ` ${state.header.provider}/${state.header.model}`;
129
+ const middle = ` ${state.header.provider}/${state.header.model}${state.header.effort ? ` · ${state.header.effort}` : ''}`;
130
130
  const tokens = state.tokens?.prompt ? ` ctx ${formatK(state.tokens.prompt)}` : '';
131
131
  const right = state.streaming ? `generando…${tokens} ` : `${state.header.cwd ?? ''}${tokens} `;
132
132
  const pad = Math.max(0, width - chars(left).length - chars(middle).length - chars(right).length);
@@ -60,6 +60,7 @@ export async function runAgent({
60
60
  maxDurationMs = 0,
61
61
  maxOutputTokens = 0,
62
62
  promptCaching = false,
63
+ reasoningEffort = '',
63
64
  repeatLimit = 3,
64
65
  wrapUp = true,
65
66
  stallWarnMs = 90000,
@@ -148,6 +149,7 @@ export async function runAgent({
148
149
  signal,
149
150
  maxOutputTokens,
150
151
  promptCaching,
152
+ reasoningEffort,
151
153
  onEvent: (event) => {
152
154
  if (event && (event.type === 'reasoning' || event.type === 'tool_calls')) emitted = true;
153
155
  if (event?.type === 'text' && event.delta) emitted = true;
@@ -274,7 +276,7 @@ export async function runAgent({
274
276
 
275
277
  let wrappedUp = false;
276
278
  if (wrapUp && (stopReason === 'max_rounds' || stopReason === 'no_progress' || stopReason === 'budget')) {
277
- const wrap = await runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries: retries, retryDelayMs, maxOutputTokens, promptCaching });
279
+ const wrap = await runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries: retries, retryDelayMs, maxOutputTokens, promptCaching, reasoningEffort });
278
280
  if (wrap.usage) accumulateUsage(wrap.usage);
279
281
  if (wrap.content) {
280
282
  working.push({ role: 'assistant', content: wrap.content, tokens_in: wrap.usage?.prompt_tokens, tokens_out: wrap.usage?.completion_tokens });
@@ -297,7 +299,7 @@ export async function runAgent({
297
299
  }
298
300
 
299
301
  /** Ronda final con tools deshabilitadas para forzar un cierre con texto. */
300
- async function runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries, retryDelayMs, maxOutputTokens, promptCaching }) {
302
+ async function runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries, retryDelayMs, maxOutputTokens, promptCaching, reasoningEffort }) {
301
303
  try {
302
304
  const result = await withRetry(
303
305
  () =>
@@ -308,6 +310,7 @@ async function runFinalAnswer({ provider, config, model, working, signal, onEven
308
310
  signal,
309
311
  maxOutputTokens,
310
312
  promptCaching,
313
+ reasoningEffort,
311
314
  onEvent,
312
315
  }),
313
316
  { retries: maxRetries, baseDelayMs: retryDelayMs, signal }
@@ -0,0 +1,141 @@
1
+ // Subagentes: definiciones en markdown con frontmatter (`<casa>/agents/<id>/AGENT.md`
2
+ // o `<cwd>/.tamnora/agents/<id>.md`). El agente principal los ve como un índice
3
+ // en el system prompt y delega en ellos con `task({ agent })`.
4
+
5
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
6
+ import { mkdir, writeFile } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+
9
+ export const AGENT_FILE = 'AGENT.md';
10
+
11
+ function parseList(value) {
12
+ if (value === undefined || value === null) return null;
13
+ const cleaned = String(value).trim().replace(/^\[|\]$/g, '');
14
+ return cleaned
15
+ .split(',')
16
+ .map((item) => item.trim().replace(/^["']|["']$/g, ''))
17
+ .filter(Boolean);
18
+ }
19
+
20
+ /** Parse an AGENT.md: frontmatter `--- key: value ---` plus the body prompt. */
21
+ export function parseAgentMarkdown(text, fallbackId = '') {
22
+ const source = String(text ?? '');
23
+ const agent = {
24
+ id: fallbackId,
25
+ name: fallbackId,
26
+ description: '',
27
+ model: '',
28
+ provider: '',
29
+ mode: '',
30
+ effort: '',
31
+ maxRounds: 0,
32
+ tools: null,
33
+ skills: null,
34
+ body: source.trim(),
35
+ };
36
+ const match = source.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?([\s\S]*)$/);
37
+ if (!match) return agent;
38
+
39
+ agent.body = match[2].trim();
40
+ for (const line of match[1].split(/\r?\n/)) {
41
+ const kv = line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*)$/);
42
+ if (!kv) continue;
43
+ const key = kv[1].toLowerCase();
44
+ const value = kv[2].trim().replace(/^["']|["']$/g, '');
45
+ if (key === 'name') agent.name = value || fallbackId;
46
+ else if (key === 'description') agent.description = value;
47
+ else if (key === 'model') agent.model = value;
48
+ else if (key === 'provider') agent.provider = value;
49
+ else if (key === 'mode') agent.mode = value;
50
+ else if (key === 'effort' || key === 'reasoning' || key === 'reasoning_effort') agent.effort = value;
51
+ else if (key === 'maxrounds' || key === 'max_rounds') agent.maxRounds = Number(value) || 0;
52
+ else if (key === 'tools') agent.tools = parseList(value);
53
+ else if (key === 'skills') agent.skills = parseList(value);
54
+ }
55
+ return agent;
56
+ }
57
+
58
+ /** List agents under a root (missing root -> []). Directory form wins. */
59
+ export function loadAgents(root) {
60
+ const agents = [];
61
+ let entries = [];
62
+ try {
63
+ entries = readdirSync(root, { withFileTypes: true });
64
+ } catch {
65
+ return agents;
66
+ }
67
+ for (const entry of entries) {
68
+ const full = path.join(root, entry.name);
69
+ const file = entry.isDirectory() ? path.join(full, AGENT_FILE) : entry.name.toLowerCase().endsWith('.md') ? full : null;
70
+ if (!file) continue;
71
+ try {
72
+ if (!statSync(file).isFile()) continue;
73
+ const id = entry.isDirectory() ? entry.name : entry.name.replace(/\.md$/i, '');
74
+ const parsed = parseAgentMarkdown(readFileSync(file, 'utf8'), id);
75
+ agents.push({ ...parsed, id, path: file });
76
+ } catch {
77
+ // ignore unreadable agents
78
+ }
79
+ }
80
+ return agents.sort((a, b) => a.id.localeCompare(b.id, 'es'));
81
+ }
82
+
83
+ /** Merge several roots; later roots win on id collisions (workspace over casa). */
84
+ export function mergeAgents(rootLists = []) {
85
+ const byId = new Map();
86
+ for (const list of rootLists) for (const agent of list) byId.set(agent.id, agent);
87
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id, 'es'));
88
+ }
89
+
90
+ /**
91
+ * Índice compacto para el system prompt: qué subagentes hay y cómo delegar.
92
+ * Sin agentes devuelve '' (no ensucia el contexto).
93
+ */
94
+ export function buildAgentsIndex(agents = []) {
95
+ if (!agents.length) return '';
96
+ const lines = [
97
+ 'Tenés subagentes disponibles. Para delegarles una subtarea, usá `task({ agent: "<id>", prompt: "..." })` con una instrucción autocontenida:',
98
+ ];
99
+ for (const agent of agents) {
100
+ const title = agent.name || agent.id;
101
+ const desc = String(agent.description || '').replace(/\s+/g, ' ').trim();
102
+ lines.push(`- ${agent.id}: ${title}${desc ? ` — ${desc}` : ''}`);
103
+ }
104
+ return lines.join('\n');
105
+ }
106
+
107
+ /** Ficha del subagente para su propio system prompt. */
108
+ export function buildSubagentPrompt(agent, { basePrompt = '' } = {}) {
109
+ const parts = [];
110
+ if (basePrompt) parts.push(basePrompt);
111
+ parts.push(
112
+ [
113
+ `Sos el subagente "${agent.name || agent.id}" de Tamnora.`,
114
+ agent.description ? `Rol: ${agent.description}` : '',
115
+ 'Ejecutá de forma autónoma la tarea encomendada con las herramientas disponibles y devolvé un resumen conciso y concreto de lo que encontraste o hiciste (archivos, hallazgos, decisiones). No pidas confirmaciones: si algo no es posible, explicalo en el resumen.',
116
+ agent.body ? `\nInstrucciones del subagente:\n${agent.body}` : '',
117
+ ]
118
+ .filter(Boolean)
119
+ .join('\n')
120
+ );
121
+ return parts.join('\n\n---\n\n');
122
+ }
123
+
124
+ /** Escribe el AGENT.md de un subagente (con guarda de traversal en el id). */
125
+ export async function writeAgentFile(dir, agent) {
126
+ const id = String(agent.id ?? '').trim();
127
+ if (!id || !/^[a-z0-9][a-z0-9_-]{0,39}$/i.test(id)) throw new Error('Id de subagente inválido.');
128
+ const lines = ['---', `name: ${agent.name || id}`];
129
+ if (agent.description) lines.push(`description: ${agent.description}`);
130
+ if (agent.model) lines.push(`model: ${agent.model}`);
131
+ if (agent.provider) lines.push(`provider: ${agent.provider}`);
132
+ if (agent.mode) lines.push(`mode: ${agent.mode}`);
133
+ if (agent.effort) lines.push(`effort: ${agent.effort}`);
134
+ if (agent.maxRounds > 0) lines.push(`maxRounds: ${Math.round(agent.maxRounds)}`);
135
+ if (Array.isArray(agent.tools)) lines.push(`tools: ${agent.tools.join(', ')}`);
136
+ if (Array.isArray(agent.skills)) lines.push(`skills: ${agent.skills.join(', ')}`);
137
+ lines.push('---', '', String(agent.body ?? '').trim(), '');
138
+ await mkdir(dir, { recursive: true });
139
+ await writeFile(path.join(dir, AGENT_FILE), lines.join('\n'), 'utf8');
140
+ return path.join(dir, AGENT_FILE);
141
+ }
@@ -12,6 +12,7 @@ export const DEFAULT_CONFIG = {
12
12
  maxDurationMs: 0,
13
13
  maxOutputTokens: 0,
14
14
  promptCaching: false,
15
+ effort: '',
15
16
  skills: { mode: 'lazy' },
16
17
  compact: { enabled: true, threshold: 0.75, keepLast: 8 },
17
18
  rules: ['.tamnora/rules/*.md'],
@@ -32,6 +32,7 @@ export {
32
32
  testProviderConnection,
33
33
  streamChat,
34
34
  } from './providers/index.js';
35
+ export { EFFORT_LEVELS, EFFORT_LABELS, normalizeEffort, applyReasoningEffort } from './providers/reasoning.js';
35
36
  export {
36
37
  connectMcpServer,
37
38
  connectMcpHttpServer,
@@ -108,6 +109,15 @@ export {
108
109
  buildSkillsPrompt,
109
110
  buildSkillsIndex,
110
111
  } from './skills/index.js';
112
+ export {
113
+ AGENT_FILE,
114
+ parseAgentMarkdown,
115
+ loadAgents,
116
+ mergeAgents,
117
+ buildAgentsIndex,
118
+ buildSubagentPrompt,
119
+ writeAgentFile,
120
+ } from './agents/index.js';
111
121
  export { parseSkillRef, fetchSkillFromGithub, searchSkillsGithub } from './skills/remote.js';
112
122
  export { SKILLS_CATALOG, skillCatalogEntry } from './skills/catalog.js';
113
123
  export {
@@ -12,11 +12,12 @@ export const PROVIDERS = [
12
12
  ];
13
13
 
14
14
  /** Lightweight metadata for the settings UI. */
15
- export const PROVIDER_META = PROVIDERS.map(({ id, label, defaultBaseUrl, requiresKey }) => ({
15
+ export const PROVIDER_META = PROVIDERS.map(({ id, label, defaultBaseUrl, requiresKey, reasoning }) => ({
16
16
  id,
17
17
  label,
18
18
  defaultBaseUrl,
19
19
  requiresKey,
20
+ reasoning: reasoning ?? 'none',
20
21
  }));
21
22
 
22
23
  /**
@@ -31,6 +32,7 @@ export function createCustomProvider(def) {
31
32
  defaultBaseUrl: def.baseUrl,
32
33
  requiresKey: true,
33
34
  custom: true,
35
+ reasoning: def.reasoning ?? 'none',
34
36
  manualModels: def.models ?? [],
35
37
  };
36
38
  }
@@ -1,4 +1,5 @@
1
1
  import { readNDJSON } from './stream.js';
2
+ import { applyReasoningEffort } from './reasoning.js';
2
3
  import { joinUrl, providerError, providerFetch, sortModels } from './utils.js';
3
4
 
4
5
  const OLLAMA_BASE_URL = 'http://localhost:11434';
@@ -44,6 +45,7 @@ export const ollamaProvider = {
44
45
  label: 'Ollama (local)',
45
46
  defaultBaseUrl: OLLAMA_BASE_URL,
46
47
  requiresKey: false,
48
+ reasoning: 'think',
47
49
 
48
50
  async listModels({ baseUrl }) {
49
51
  const res = await providerFetch(joinUrl(baseUrl, '/api/tags'));
@@ -52,10 +54,11 @@ export const ollamaProvider = {
52
54
  return sortModels((data.models ?? []).map(toOllamaModel).filter((m) => m.id));
53
55
  },
54
56
 
55
- async streamChat({ baseUrl }, { model, messages, tools, signal, onEvent, maxOutputTokens }) {
57
+ async streamChat({ baseUrl }, { model, messages, tools, signal, onEvent, maxOutputTokens, reasoningEffort }) {
56
58
  const body = { model, messages: toOllamaMessages(messages), stream: true };
57
59
  if (tools?.length) body.tools = tools;
58
60
  if (maxOutputTokens > 0) body.options = { ...(body.options ?? {}), num_predict: maxOutputTokens };
61
+ applyReasoningEffort(body, reasoningEffort, this);
59
62
 
60
63
  const res = await providerFetch(joinUrl(baseUrl, '/api/chat'), {
61
64
  method: 'POST',
@@ -1,5 +1,6 @@
1
1
  import { readOpenAISSE } from './stream.js';
2
2
  import { applyPromptCache } from './cache.js';
3
+ import { applyReasoningEffort } from './reasoning.js';
3
4
  import { joinUrl, authHeaders, providerError, providerFetch, normalizeModel, sortModels } from './utils.js';
4
5
 
5
6
  /**
@@ -11,6 +12,9 @@ export const openaiCompatibleProvider = {
11
12
  label: 'OpenAI-compatible',
12
13
  defaultBaseUrl: 'https://api.openai.com/v1',
13
14
  requiresKey: true,
15
+ // El adaptador generico no sabe si el servidor entiende `reasoning_effort`;
16
+ // los presets que si lo soportan lo declaran (`reasoning: 'effort'`).
17
+ reasoning: 'none',
14
18
 
15
19
  async listModels({ baseUrl, apiKey, headers }) {
16
20
  const res = await providerFetch(joinUrl(baseUrl, '/models'), {
@@ -22,7 +26,7 @@ export const openaiCompatibleProvider = {
22
26
  return sortModels(list.map(normalizeModel).filter((m) => m.id));
23
27
  },
24
28
 
25
- async streamChat({ baseUrl, apiKey, headers }, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching }) {
29
+ async streamChat({ baseUrl, apiKey, headers }, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching, reasoningEffort }) {
26
30
  const buildBody = () => {
27
31
  const body = { model, messages, stream: true };
28
32
  if (tools?.length) body.tools = tools;
@@ -32,6 +36,7 @@ export const openaiCompatibleProvider = {
32
36
  body[field] = maxOutputTokens;
33
37
  }
34
38
  if (promptCaching) applyPromptCache(body, { mode: this?.caching ?? 'none' });
39
+ applyReasoningEffort(body, reasoningEffort, this);
35
40
  return body;
36
41
  };
37
42
  const run = (body) =>
@@ -1,5 +1,6 @@
1
1
  import { requestStream } from './openaiCompatible.js';
2
2
  import { applyPromptCache } from './cache.js';
3
+ import { applyReasoningEffort } from './reasoning.js';
3
4
  import { joinUrl, providerFetch, normalizeModel, sortModels } from './utils.js';
4
5
 
5
6
  const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
@@ -26,6 +27,8 @@ export const openrouterProvider = {
26
27
  requiresKey: true,
27
28
  // OpenRouter pasa cache_control a Anthropic; en otros modelos queda sin efecto.
28
29
  caching: 'anthropic',
30
+ // OpenRouter acepta el enum completo (max/xhigh/high/medium/low/minimal/none).
31
+ reasoning: 'effort',
29
32
 
30
33
  async listModels({ apiKey, siteUrl, siteName }) {
31
34
  const res = await providerFetch(joinUrl(OPENROUTER_BASE_URL, '/models'), {
@@ -41,11 +44,12 @@ export const openrouterProvider = {
41
44
  ).sort((a, b) => Number(b.free) - Number(a.free));
42
45
  },
43
46
 
44
- async streamChat(ctx, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching }) {
47
+ async streamChat(ctx, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching, reasoningEffort }) {
45
48
  const body = { model, messages, stream: true };
46
49
  if (tools?.length) body.tools = tools;
47
50
  if (maxOutputTokens > 0) body.max_tokens = maxOutputTokens;
48
51
  if (promptCaching) applyPromptCache(body, { mode: this?.caching ?? 'none' });
52
+ applyReasoningEffort(body, reasoningEffort, this);
49
53
 
50
54
  return requestStream({
51
55
  url: joinUrl(OPENROUTER_BASE_URL, '/chat/completions'),
@@ -7,18 +7,19 @@ function preset(id, label, baseUrl, extra = {}) {
7
7
  }
8
8
 
9
9
  export const OPENAI_COMPATIBLE_PRESETS = [
10
- preset('deepseek', 'DeepSeek', 'https://api.deepseek.com/v1'),
10
+ preset('deepseek', 'DeepSeek', 'https://api.deepseek.com/v1', { reasoning: 'deepseek' }),
11
11
  preset('openai', 'OpenAI', 'https://api.openai.com/v1', {
12
12
  maxTokensField: 'max_completion_tokens',
13
13
  caching: 'openai',
14
+ reasoning: 'openai',
14
15
  }),
15
16
  // Anthropic y Gemini exponen una capa compatible con OpenAI (chat/completions
16
17
  // + tools) que usa el mismo Bearer; sirven con este adaptador sin cambios.
17
18
  preset('anthropic', 'Anthropic (Claude)', 'https://api.anthropic.com/v1', { caching: 'anthropic' }),
18
- preset('gemini', 'Google Gemini', 'https://generativelanguage.googleapis.com/v1beta/openai'),
19
+ preset('gemini', 'Google Gemini', 'https://generativelanguage.googleapis.com/v1beta/openai', { reasoning: 'effort' }),
19
20
  preset('groq', 'Groq', 'https://api.groq.com/openai/v1'),
20
21
  preset('mistral', 'Mistral AI', 'https://api.mistral.ai/v1'),
21
- preset('xai', 'xAI (Grok)', 'https://api.x.ai/v1'),
22
+ preset('xai', 'xAI (Grok)', 'https://api.x.ai/v1', { reasoning: 'effort' }),
22
23
  preset('together', 'Together AI', 'https://api.together.xyz/v1'),
23
24
  preset('fireworks', 'Fireworks AI', 'https://api.fireworks.ai/inference/v1'),
24
25
  preset('cerebras', 'Cerebras', 'https://api.cerebras.ai/v1'),
@@ -0,0 +1,58 @@
1
+ // Esfuerzo de razonamiento: un vocabulario comun que cada adaptador traduce al
2
+ // parametro de su proveedor. `''` significa "sin override" (default del modelo).
3
+
4
+ export const EFFORT_LEVELS = ['off', 'low', 'medium', 'high', 'max'];
5
+
6
+ export const EFFORT_LABELS = {
7
+ off: 'Sin razonamiento',
8
+ low: 'Bajo',
9
+ medium: 'Medio',
10
+ high: 'Alto',
11
+ max: 'Máximo',
12
+ };
13
+
14
+ const ALIASES = {
15
+ none: 'off',
16
+ minimal: 'low',
17
+ medium: 'medium',
18
+ xhigh: 'max',
19
+ ultra: 'max',
20
+ };
21
+
22
+ /** Normaliza lo que manda el usuario/proveedor al vocabulario comun. */
23
+ export function normalizeEffort(value) {
24
+ if (value === null || value === undefined) return '';
25
+ const raw = String(value).trim().toLowerCase();
26
+ if (!raw || raw === 'auto' || raw === 'default') return '';
27
+ const level = ALIASES[raw] ?? raw;
28
+ return EFFORT_LEVELS.includes(level) ? level : '';
29
+ }
30
+
31
+ /**
32
+ * Agrega al body del request el parametro de esfuerzo que entiende el
33
+ * proveedor. Los adaptadores que no lo soportan (`reasoning: 'none'`) no
34
+ * reciben nada, asi un modelo sin razonamiento no rompe con un 400.
35
+ */
36
+ export function applyReasoningEffort(body, effort, provider = {}) {
37
+ const level = normalizeEffort(effort);
38
+ if (!level) return body;
39
+ const mode = provider?.reasoning ?? 'none';
40
+
41
+ if (mode === 'think') {
42
+ // Ollama: `think` acepta boolean o (en versiones nuevas) un nivel.
43
+ body.think = level === 'off' ? false : level;
44
+ return body;
45
+ }
46
+
47
+ if (mode === 'effort' || mode === 'openai' || mode === 'deepseek') {
48
+ // OpenAI solo llega hasta high; DeepSeek y OpenRouter aceptan max.
49
+ const effortValue = level === 'max' && mode === 'openai' ? 'high' : level === 'off' ? 'none' : level;
50
+ body.reasoning_effort = effortValue;
51
+ if (mode === 'deepseek') {
52
+ body.thinking = { type: level === 'off' ? 'disabled' : 'enabled' };
53
+ }
54
+ return body;
55
+ }
56
+
57
+ return body;
58
+ }