@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.
- package/CHANGELOG.md +6 -0
- package/harness/packages/cli/src/commands/repl.js +18 -2
- package/harness/packages/cli/src/commands/run.js +5 -1
- package/harness/packages/cli/src/commands/tui.js +5 -1
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +22 -2
- package/harness/packages/cli/src/tui/layout.js +1 -1
- package/harness/packages/core/src/agent/runAgent.js +5 -2
- package/harness/packages/core/src/agents/index.js +141 -0
- package/harness/packages/core/src/config/index.js +1 -0
- package/harness/packages/core/src/index.js +10 -0
- package/harness/packages/core/src/providers/index.js +3 -1
- package/harness/packages/core/src/providers/ollama.js +4 -1
- package/harness/packages/core/src/providers/openaiCompatible.js +6 -1
- package/harness/packages/core/src/providers/openrouter.js +5 -1
- package/harness/packages/core/src/providers/presets.js +4 -3
- package/harness/packages/core/src/providers/reasoning.js +58 -0
- package/harness/packages/core/test/agents.test.js +111 -0
- package/harness/packages/core/test/reasoning.test.js +43 -0
- package/harness/packages/server/public/app.js +37 -0
- package/harness/packages/server/public/chat.js +1 -0
- package/harness/packages/server/public/chrome.js +94 -1
- package/harness/packages/server/public/core.js +1 -1
- package/harness/packages/server/public/icons (1).zip +0 -0
- package/harness/packages/server/public/index.html +90 -1
- package/harness/packages/server/public/sessions.js +1 -0
- package/harness/packages/server/public/settings.js +167 -0
- package/harness/packages/server/public/settingsNav.js +1 -0
- package/harness/packages/server/src/lib/agents.js +74 -0
- package/harness/packages/server/src/lib/channels/telegram/run.js +1 -0
- package/harness/packages/server/src/lib/prompt.js +11 -3
- package/harness/packages/server/src/lib/scheduler.js +4 -0
- package/harness/packages/server/src/lib/sessionView.js +1 -0
- package/harness/packages/server/src/lib/skills.js +13 -0
- package/harness/packages/server/src/lib/tasks.js +71 -23
- package/harness/packages/server/src/routes/agents.js +88 -0
- package/harness/packages/server/src/routes/chat.js +9 -1
- package/harness/packages/server/src/routes/providers.js +7 -0
- package/harness/packages/server/src/routes/sessions.js +3 -1
- package/harness/packages/server/src/server.js +7 -0
- package/harness/packages/server/test/agents.test.js +194 -0
- package/harness/packages/server/test/consoleTabs.test.js +2 -2
- package/harness/packages/server/test/settingsNav.test.js +2 -2
- package/harness/tamnora.example.json +1 -0
- 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
|
+
}
|
|
@@ -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
|
+
}
|