@danieltmn/tamnora 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/SECURITY.md +6 -0
- package/harness/packages/cli/src/commands/repl.js +2 -0
- package/harness/packages/cli/src/commands/run.js +2 -0
- package/harness/packages/cli/src/commands/tui.js +2 -0
- package/harness/packages/cli/src/lib/runtime.js +4 -0
- package/harness/packages/cli/src/tui/app.js +4 -0
- package/harness/packages/core/src/agent/runAgent.js +131 -47
- package/harness/packages/core/src/config/index.js +3 -1
- package/harness/packages/core/src/config/secrets.js +36 -0
- package/harness/packages/core/src/eval/runTask.js +2 -11
- package/harness/packages/core/src/index.js +13 -1
- package/harness/packages/core/src/mcp/common.js +15 -11
- package/harness/packages/core/src/mcp/index.js +1 -1
- package/harness/packages/core/src/mcp/registry.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +41 -2
- package/harness/packages/core/src/redact.js +104 -0
- package/harness/packages/core/test/agent.test.js +93 -0
- package/harness/packages/core/test/mcp.test.js +67 -0
- package/harness/packages/core/test/redact.test.js +45 -0
- package/harness/packages/server/public/app.js +222 -18
- package/harness/packages/server/public/index.html +93 -26
- package/harness/packages/server/public/styles.css +61 -1
- package/harness/packages/server/src/lib/agentTools.js +9 -3
- package/harness/packages/server/src/lib/channels/index.js +11 -4
- package/harness/packages/server/src/lib/channels/telegram.js +28 -10
- package/harness/packages/server/src/routes/channels.js +1 -0
- package/harness/packages/server/src/routes/chat.js +8 -1
- package/harness/packages/server/src/routes/mcp.js +29 -5
- package/harness/packages/server/src/routes/providers.js +16 -15
- package/harness/packages/server/src/server.js +13 -0
- package/harness/packages/server/test/server.test.js +78 -2
- package/harness/tamnora.example.json +17 -1
- package/package.json +1 -1
|
@@ -42,6 +42,8 @@ const DEFINITIONS = [
|
|
|
42
42
|
transport: { type: 'string', enum: ['stdio', 'http'] },
|
|
43
43
|
command: { type: 'string', description: 'Para stdio, ej. "node" o "npx"' },
|
|
44
44
|
args: { type: 'array', items: { type: 'string' } },
|
|
45
|
+
env: { type: 'object', description: 'Variables para el MCP (usá "env:VAR" para no hardcodear secretos)' },
|
|
46
|
+
inheritEnv: { type: 'boolean', description: 'Heredar process.env (solo MCPs de confianza; default false)' },
|
|
45
47
|
url: { type: 'string', description: 'Para http' },
|
|
46
48
|
enabled: { type: 'boolean' },
|
|
47
49
|
},
|
|
@@ -133,7 +135,7 @@ export function createAgentTools(ctx) {
|
|
|
133
135
|
command: cfg.command ?? null,
|
|
134
136
|
url: cfg.url ?? null,
|
|
135
137
|
enabled: true,
|
|
136
|
-
config: { args: cfg.args ?? [], env: cfg.env ?? null },
|
|
138
|
+
config: { args: cfg.args ?? [], env: cfg.env ?? null, inheritEnv: cfg.inheritEnv === true },
|
|
137
139
|
});
|
|
138
140
|
await ctx.reloadMcp();
|
|
139
141
|
const required = (cfg.requiredEnv ?? []).length ? ` Requiere env: ${cfg.requiredEnv.join(', ')}.` : '';
|
|
@@ -154,7 +156,7 @@ export function createAgentTools(ctx) {
|
|
|
154
156
|
command: cfg.command ?? null,
|
|
155
157
|
url: cfg.url ?? null,
|
|
156
158
|
enabled: cfg.enabled !== false,
|
|
157
|
-
config: { args: cfg.args ?? [], env: cfg.env ?? null },
|
|
159
|
+
config: { args: cfg.args ?? [], env: cfg.env ?? null, inheritEnv: cfg.inheritEnv === true },
|
|
158
160
|
});
|
|
159
161
|
await ctx.reloadMcp();
|
|
160
162
|
return `MCP "${cfg.id}" importado.`;
|
|
@@ -174,7 +176,11 @@ export function createAgentTools(ctx) {
|
|
|
174
176
|
command: transport === 'stdio' ? command : null,
|
|
175
177
|
url: transport === 'http' ? url : null,
|
|
176
178
|
enabled: args.enabled !== false,
|
|
177
|
-
config: {
|
|
179
|
+
config: {
|
|
180
|
+
args: Array.isArray(args.args) ? args.args : [],
|
|
181
|
+
env: args.env ?? null,
|
|
182
|
+
inheritEnv: args.inheritEnv === true,
|
|
183
|
+
},
|
|
178
184
|
});
|
|
179
185
|
await ctx.reloadMcp();
|
|
180
186
|
return `MCP "${id}" registrado (${transport}).`;
|
|
@@ -36,6 +36,7 @@ export function createChannelsManager(ctx, { log = () => {}, enabled = true } =
|
|
|
36
36
|
if (cfg.token) tg.token = cfg.token;
|
|
37
37
|
if (Array.isArray(cfg.allowedChatIds) && cfg.allowedChatIds.length) tg.allowedChatIds = cfg.allowedChatIds;
|
|
38
38
|
if (cfg.transcribeModel) tg.transcribeModel = cfg.transcribeModel;
|
|
39
|
+
if (cfg.transcribeProvider) tg.transcribeProvider = cfg.transcribeProvider;
|
|
39
40
|
return tg;
|
|
40
41
|
};
|
|
41
42
|
|
|
@@ -43,7 +44,12 @@ export function createChannelsManager(ctx, { log = () => {}, enabled = true } =
|
|
|
43
44
|
const tg = readConfig();
|
|
44
45
|
const token = tg.token || process.env.TAMNORA_TELEGRAM_TOKEN || '';
|
|
45
46
|
const allowedChatIds = parseAllowed(tg.allowedChatIds ?? process.env.TAMNORA_TELEGRAM_CHAT_IDS ?? []);
|
|
46
|
-
return {
|
|
47
|
+
return {
|
|
48
|
+
token,
|
|
49
|
+
allowedChatIds,
|
|
50
|
+
transcribeModel: tg.transcribeModel,
|
|
51
|
+
transcribeProvider: tg.transcribeProvider,
|
|
52
|
+
};
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
function stop() {
|
|
@@ -56,9 +62,9 @@ export function createChannelsManager(ctx, { log = () => {}, enabled = true } =
|
|
|
56
62
|
function load() {
|
|
57
63
|
stop();
|
|
58
64
|
if (!enabled) return { enabled: false, channels: [] };
|
|
59
|
-
const { token, allowedChatIds, transcribeModel } = effective();
|
|
65
|
+
const { token, allowedChatIds, transcribeModel, transcribeProvider } = effective();
|
|
60
66
|
const channels = [];
|
|
61
|
-
const telegram = startTelegramChannel(ctx, { token, allowedChatIds, transcribeModel, log });
|
|
67
|
+
const telegram = startTelegramChannel(ctx, { token, allowedChatIds, transcribeModel, transcribeProvider, log });
|
|
62
68
|
if (telegram) channels.push(telegram);
|
|
63
69
|
const unsubscribe = ctx.runs?.subscribeAll?.((sessionId, event) => {
|
|
64
70
|
for (const channel of channels) {
|
|
@@ -75,12 +81,13 @@ export function createChannelsManager(ctx, { log = () => {}, enabled = true } =
|
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
function status() {
|
|
78
|
-
const { token, allowedChatIds, transcribeModel } = effective();
|
|
84
|
+
const { token, allowedChatIds, transcribeModel, transcribeProvider } = effective();
|
|
79
85
|
return {
|
|
80
86
|
tokenSet: Boolean(token),
|
|
81
87
|
tokenHint: tokenHint(token),
|
|
82
88
|
allowedChatIds,
|
|
83
89
|
transcribeModel: transcribeModel || 'whisper-1',
|
|
90
|
+
transcribeProvider: transcribeProvider || '',
|
|
84
91
|
running: Boolean(active && active.channels.length),
|
|
85
92
|
};
|
|
86
93
|
}
|
|
@@ -75,14 +75,17 @@ export function completionFooter(stopReason, rounds, toolCalls, durationMs) {
|
|
|
75
75
|
const parts = [`${rounds ?? '?'} ronda${rounds === 1 ? '' : 's'}`];
|
|
76
76
|
if (toolCalls) parts.push(`${toolCalls} herramienta${toolCalls === 1 ? '' : 's'}`);
|
|
77
77
|
if (durationMs > 0) parts.push(durationMs >= 1000 ? `${(durationMs / 1000).toFixed(1)}s` : `${durationMs}ms`);
|
|
78
|
-
const maxed =
|
|
79
|
-
return `${maxed ? '⚠
|
|
78
|
+
const maxed = ['max_rounds', 'no_progress', 'budget'].includes(stopReason);
|
|
79
|
+
return `${maxed ? '⚠ corte de seguridad' : '✓ terminado'} · ${parts.join(' · ')}`;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
83
|
* Arranca el canal de Telegram. Devuelve `{ stop, notify }` o `null` sin token.
|
|
84
84
|
*/
|
|
85
|
-
export function startTelegramChannel(
|
|
85
|
+
export function startTelegramChannel(
|
|
86
|
+
ctx,
|
|
87
|
+
{ token, allowedChatIds = [], transcribeModel = 'whisper-1', transcribeProvider = '', log = () => {} } = {}
|
|
88
|
+
) {
|
|
86
89
|
if (!token) return null;
|
|
87
90
|
const allowed = new Set(allowedChatIds.map(String));
|
|
88
91
|
const controllers = new Map();
|
|
@@ -130,10 +133,11 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
|
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
async function transcribe(buffer, filename) {
|
|
133
|
-
const providerId = ctx.effectiveProviderId();
|
|
134
|
-
const
|
|
136
|
+
const providerId = transcribeProvider || ctx.effectiveProviderId();
|
|
137
|
+
const settings = ctx.providerSettings(providerId) ?? {};
|
|
138
|
+
const baseUrl = (settings.baseUrl || '').replace(/\/+$/, '');
|
|
135
139
|
const apiKey = ctx.apiKeyFor(providerId);
|
|
136
|
-
if (!baseUrl) throw new Error(
|
|
140
|
+
if (!baseUrl) throw new Error(`el proveedor "${providerId}" no tiene baseUrl para transcribir`);
|
|
137
141
|
const form = new FormData();
|
|
138
142
|
form.append('file', new Blob([buffer]), filename);
|
|
139
143
|
form.append('model', transcribeModel);
|
|
@@ -142,7 +146,15 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
|
|
|
142
146
|
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
|
143
147
|
body: form,
|
|
144
148
|
});
|
|
145
|
-
if (!res.ok)
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
const hint =
|
|
151
|
+
res.status === 404
|
|
152
|
+
? ` — "${providerId}" no expone /audio/transcriptions`
|
|
153
|
+
: '';
|
|
154
|
+
throw new Error(
|
|
155
|
+
`transcripción falló (HTTP ${res.status})${hint}. Elegí un proveedor con voz (OpenAI, Groq…) en Ajustes → Telegram.`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
146
158
|
const data = await res.json();
|
|
147
159
|
return String(data.text ?? '').trim();
|
|
148
160
|
}
|
|
@@ -237,7 +249,9 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
|
|
|
237
249
|
tools: definitions,
|
|
238
250
|
execute,
|
|
239
251
|
signal: controller.signal,
|
|
240
|
-
maxRounds: ctx.config.maxRounds,
|
|
252
|
+
maxRounds: ctx.effectiveMaxRounds?.() ?? ctx.config.maxRounds,
|
|
253
|
+
maxTokens: ctx.effectiveMaxTokens?.() ?? 0,
|
|
254
|
+
maxDurationMs: ctx.effectiveMaxDurationMs?.() ?? 0,
|
|
241
255
|
onEvent(event) {
|
|
242
256
|
if (event.type === 'text') {
|
|
243
257
|
full = event.full;
|
|
@@ -260,10 +274,14 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
|
|
|
260
274
|
const durationMs = Date.now() - startedAt;
|
|
261
275
|
let body = result.content?.trim() || full.trim();
|
|
262
276
|
if (!body) {
|
|
263
|
-
if (result.stopReason === '
|
|
277
|
+
if (result.stopReason === 'no_progress') {
|
|
278
|
+
body = '⚠ El agente se quedó repitiendo las mismas herramientas sin avanzar.';
|
|
279
|
+
} else if (result.stopReason === 'budget') {
|
|
280
|
+
body = '⚠ El agente alcanzó el presupuesto de tokens/tiempo sin respuesta final.';
|
|
281
|
+
} else if (result.stopReason === 'max_rounds') {
|
|
264
282
|
body = `⚠ El agente alcanzó el máximo de rondas (${result.rounds}) sin respuesta final.${
|
|
265
283
|
toolCalls ? ` Hizo ${toolCalls} herramientas.` : ''
|
|
266
|
-
}
|
|
284
|
+
}`;
|
|
267
285
|
} else if (toolCalls) {
|
|
268
286
|
body = '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.';
|
|
269
287
|
} else {
|
|
@@ -30,6 +30,7 @@ export function registerChannels(route, ctx) {
|
|
|
30
30
|
}
|
|
31
31
|
if ('allowedChatIds' in body) next.allowedChatIds = sanitizeAllowed(body.allowedChatIds);
|
|
32
32
|
if ('transcribeModel' in body) next.transcribeModel = String(body.transcribeModel ?? '').trim() || 'whisper-1';
|
|
33
|
+
if ('transcribeProvider' in body) next.transcribeProvider = String(body.transcribeProvider ?? '').trim();
|
|
33
34
|
|
|
34
35
|
app.telegram = next;
|
|
35
36
|
writeApp(app);
|
|
@@ -250,7 +250,9 @@ export function registerChat(route, ctx) {
|
|
|
250
250
|
tools: definitions,
|
|
251
251
|
execute,
|
|
252
252
|
signal: ctx.runs.signal(sessionId),
|
|
253
|
-
maxRounds: Number(body.maxRounds) || ctx.
|
|
253
|
+
maxRounds: Number(body.maxRounds) || ctx.effectiveMaxRounds?.() || MAX_TOOL_ROUNDS,
|
|
254
|
+
maxTokens: Number(body.maxTokens) || ctx.effectiveMaxTokens?.() || 0,
|
|
255
|
+
maxDurationMs: Number(body.maxDurationMs) || ctx.effectiveMaxDurationMs?.() || 0,
|
|
254
256
|
stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
|
|
255
257
|
contextTokens: Number(body.contextTokens) || ctx.config.contextTokens || 0,
|
|
256
258
|
onEvent(event) {
|
|
@@ -281,6 +283,9 @@ export function registerChat(route, ctx) {
|
|
|
281
283
|
const { preview, truncated } = previewToolResult(event.name, event.result);
|
|
282
284
|
const failed = /^Error|^Permiso denegado/i.test(String(event.result));
|
|
283
285
|
ctx.runs.emit(sessionId, 'tool_result', { id: event.id, name: event.name, preview, truncated, failed });
|
|
286
|
+
} else if (event.type === 'repeat') {
|
|
287
|
+
partials.flush();
|
|
288
|
+
ctx.runs.emit(sessionId, 'repeat', { name: event.name, count: event.count });
|
|
284
289
|
}
|
|
285
290
|
},
|
|
286
291
|
});
|
|
@@ -303,6 +308,8 @@ export function registerChat(route, ctx) {
|
|
|
303
308
|
content: result.content,
|
|
304
309
|
rounds: result.rounds,
|
|
305
310
|
stopReason: result.stopReason,
|
|
311
|
+
wrappedUp: result.wrappedUp === true,
|
|
312
|
+
continuable: result.stopReason !== 'end_turn',
|
|
306
313
|
tokens: result.usage ?? null,
|
|
307
314
|
toolCalls,
|
|
308
315
|
durationMs,
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getMcpRegistryServer,
|
|
5
5
|
registryServerToConfig,
|
|
6
6
|
normalizeMcpConfig,
|
|
7
|
+
maskEnv,
|
|
7
8
|
} from '../lib/core.js';
|
|
8
9
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
9
10
|
|
|
@@ -27,11 +28,26 @@ function listServers(ctx) {
|
|
|
27
28
|
command: server.command ?? null,
|
|
28
29
|
args: Array.isArray(server.args) ? server.args : [],
|
|
29
30
|
url: server.url ?? null,
|
|
30
|
-
|
|
31
|
+
inheritEnv: server.inheritEnv === true,
|
|
32
|
+
env: maskEnv(server.env),
|
|
31
33
|
}));
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Conserva los valores secretos que la API devuelve enmascarados (`••••…`):
|
|
38
|
+
* un cliente que solo cambia `enabled` o un campo no debe pisar el secreto.
|
|
39
|
+
*/
|
|
40
|
+
function mergeEnv(existing, incoming) {
|
|
41
|
+
const stored = existing?.config?.env ?? existing?.env ?? {};
|
|
42
|
+
const result = {};
|
|
43
|
+
for (const [key, value] of Object.entries(incoming ?? {})) {
|
|
44
|
+
const text = String(value ?? '');
|
|
45
|
+
result[key] = text.startsWith('••••') && stored[key] !== undefined ? stored[key] : value;
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseServer(body, id, existing = null) {
|
|
35
51
|
const transport = body.transport === 'http' ? 'http' : 'stdio';
|
|
36
52
|
const command = String(body.command ?? '').trim();
|
|
37
53
|
const url = String(body.url ?? '').trim();
|
|
@@ -49,7 +65,14 @@ function parseServer(body, id) {
|
|
|
49
65
|
command: transport === 'stdio' ? command : null,
|
|
50
66
|
url: transport === 'http' ? url : null,
|
|
51
67
|
enabled: body.enabled !== false,
|
|
52
|
-
config: {
|
|
68
|
+
config: {
|
|
69
|
+
args,
|
|
70
|
+
env: mergeEnv(existing, body.env),
|
|
71
|
+
inheritEnv:
|
|
72
|
+
body.inheritEnv === undefined
|
|
73
|
+
? Boolean(existing?.config?.inheritEnv ?? existing?.inheritEnv)
|
|
74
|
+
: body.inheritEnv === true,
|
|
75
|
+
},
|
|
53
76
|
};
|
|
54
77
|
}
|
|
55
78
|
|
|
@@ -128,7 +151,7 @@ export function registerMcp(route, ctx) {
|
|
|
128
151
|
command: record.command ?? null,
|
|
129
152
|
url: record.url ?? null,
|
|
130
153
|
enabled: record.enabled !== false,
|
|
131
|
-
config: { args: record.args ?? [], env: record.env ?? null },
|
|
154
|
+
config: { args: record.args ?? [], env: record.env ?? null, inheritEnv: record.inheritEnv === true },
|
|
132
155
|
});
|
|
133
156
|
await ctx.reloadMcp();
|
|
134
157
|
sendJson(res, 200, {
|
|
@@ -142,9 +165,10 @@ export function registerMcp(route, ctx) {
|
|
|
142
165
|
const id = String(params.id);
|
|
143
166
|
if (!ID_PATTERN.test(id)) return sendError(res, 400, 'Id de servidor inválido.');
|
|
144
167
|
const body = await readJson(req);
|
|
168
|
+
const existing = ctx.mergedMcpServers().find((s) => s.id === id) ?? null;
|
|
145
169
|
let record;
|
|
146
170
|
try {
|
|
147
|
-
record = parseServer(body, id);
|
|
171
|
+
record = parseServer(body, id, existing);
|
|
148
172
|
} catch (error) {
|
|
149
173
|
return sendError(res, 400, error.message);
|
|
150
174
|
}
|
|
@@ -68,15 +68,17 @@ export function registerProviders(route, ctx) {
|
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
const settingsView = () => ({
|
|
72
|
+
provider: ctx.effectiveProviderId(),
|
|
73
|
+
model: ctx.effectiveModel(),
|
|
74
|
+
mode: ctx.effectiveMode(),
|
|
75
|
+
maxRounds: ctx.effectiveMaxRounds(),
|
|
76
|
+
maxTokens: ctx.effectiveMaxTokens(),
|
|
77
|
+
maxDurationMs: ctx.effectiveMaxDurationMs(),
|
|
78
|
+
});
|
|
79
|
+
|
|
71
80
|
route('GET', '/api/settings', ({ res }) => {
|
|
72
|
-
sendJson(res, 200, {
|
|
73
|
-
settings: {
|
|
74
|
-
provider: ctx.effectiveProviderId(),
|
|
75
|
-
model: ctx.effectiveModel(),
|
|
76
|
-
mode: ctx.effectiveMode(),
|
|
77
|
-
maxRounds: ctx.config.maxRounds,
|
|
78
|
-
},
|
|
79
|
-
});
|
|
81
|
+
sendJson(res, 200, { settings: settingsView() });
|
|
80
82
|
});
|
|
81
83
|
|
|
82
84
|
route('PUT', '/api/settings', async ({ req, res }) => {
|
|
@@ -84,13 +86,12 @@ export function registerProviders(route, ctx) {
|
|
|
84
86
|
if (body.provider !== undefined) ctx.store.setSetting('activeProvider', String(body.provider) || null);
|
|
85
87
|
if (body.model !== undefined) ctx.store.setSetting('activeModel', String(body.model) || null);
|
|
86
88
|
if (body.mode !== undefined) ctx.store.setSetting('activeMode', resolveMode(String(body.mode)).id);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
});
|
|
89
|
+
for (const key of ['maxRounds', 'maxTokens', 'maxDurationMs']) {
|
|
90
|
+
if (body[key] === undefined) continue;
|
|
91
|
+
const value = Number(body[key]);
|
|
92
|
+
ctx.store.setSetting(key, Number.isFinite(value) && value > 0 ? Math.round(value) : 0);
|
|
93
|
+
}
|
|
94
|
+
sendJson(res, 200, { settings: settingsView() });
|
|
94
95
|
});
|
|
95
96
|
|
|
96
97
|
route('PUT', '/api/providers/:id', async ({ req, res, params }) => {
|
|
@@ -92,6 +92,7 @@ export async function createTamnoraServer({
|
|
|
92
92
|
args: Array.isArray(row.config?.args) ? row.config.args : [],
|
|
93
93
|
url: row.url ?? undefined,
|
|
94
94
|
env: row.config?.env ?? undefined,
|
|
95
|
+
inheritEnv: row.config?.inheritEnv === true,
|
|
95
96
|
enabled: row.enabled !== false,
|
|
96
97
|
source: 'store',
|
|
97
98
|
}));
|
|
@@ -137,6 +138,15 @@ export async function createTamnoraServer({
|
|
|
137
138
|
const effectiveProviderId = () => store.getSetting('activeProvider') || config.provider;
|
|
138
139
|
const effectiveModel = () => store.getSetting('activeModel') || config.model;
|
|
139
140
|
const effectiveMode = () => store.getSetting('activeMode') || 'agent';
|
|
141
|
+
const settingNumber = (key, fallback) => {
|
|
142
|
+
const value = store.getSetting(key);
|
|
143
|
+
if (value === null || value === undefined || value === '') return fallback ?? 0;
|
|
144
|
+
const parsed = Number(value);
|
|
145
|
+
return Number.isFinite(parsed) ? parsed : fallback ?? 0;
|
|
146
|
+
};
|
|
147
|
+
const effectiveMaxRounds = () => settingNumber('maxRounds', config.maxRounds);
|
|
148
|
+
const effectiveMaxTokens = () => settingNumber('maxTokens', config.maxTokens);
|
|
149
|
+
const effectiveMaxDurationMs = () => settingNumber('maxDurationMs', config.maxDurationMs);
|
|
140
150
|
|
|
141
151
|
const ctx = {
|
|
142
152
|
cwd,
|
|
@@ -152,6 +162,9 @@ export async function createTamnoraServer({
|
|
|
152
162
|
effectiveProviderId,
|
|
153
163
|
effectiveModel,
|
|
154
164
|
effectiveMode,
|
|
165
|
+
effectiveMaxRounds,
|
|
166
|
+
effectiveMaxTokens,
|
|
167
|
+
effectiveMaxDurationMs,
|
|
155
168
|
providerSettings,
|
|
156
169
|
customProviderDefs,
|
|
157
170
|
apiKeyFor: (id) => resolveApiKey(providerSettings(id).apiKey, process.env),
|
|
@@ -43,6 +43,17 @@ test('serves health, providers and the console', async () => {
|
|
|
43
43
|
|
|
44
44
|
const html = await (await fetch(`${base}/`)).text();
|
|
45
45
|
assert.match(html, /Tamnora Console/);
|
|
46
|
+
for (const hook of [
|
|
47
|
+
'data-panel="agente"',
|
|
48
|
+
'data-panel="sesiones"',
|
|
49
|
+
'id="settings-controls-slot"',
|
|
50
|
+
'id="slash-menu"',
|
|
51
|
+
'id="statusbar"',
|
|
52
|
+
]) {
|
|
53
|
+
assert.ok(html.includes(hook), `falta ${hook}`);
|
|
54
|
+
}
|
|
55
|
+
// Los controles de agente se declaran una sola vez (se mueven entre slots).
|
|
56
|
+
assert.equal((html.match(/id="mode"/g) ?? []).length, 1);
|
|
46
57
|
|
|
47
58
|
const missing = await fetch(`${base}/api/nope`);
|
|
48
59
|
assert.equal(missing.status, 404);
|
|
@@ -264,7 +275,8 @@ test('done reports max_rounds and the tool count when the model never answers',
|
|
|
264
275
|
test('completionFooter resume el cierre del turno', () => {
|
|
265
276
|
assert.equal(completionFooter('end_turn', 1, 0, 0), '✓ terminado · 1 ronda');
|
|
266
277
|
assert.equal(completionFooter('end_turn', 3, 5, 1200), '✓ terminado · 3 rondas · 5 herramientas · 1.2s');
|
|
267
|
-
assert.match(completionFooter('max_rounds', 2, 2, 300), /^⚠
|
|
278
|
+
assert.match(completionFooter('max_rounds', 2, 2, 300), /^⚠ corte de seguridad · 2 rondas · 2 herramientas · 300ms$/);
|
|
279
|
+
assert.match(completionFooter('no_progress', 2, 2, 300), /^⚠ corte de seguridad/);
|
|
268
280
|
});
|
|
269
281
|
|
|
270
282
|
test('configures Telegram from the console (masked token)', async () => {
|
|
@@ -287,10 +299,11 @@ test('configures Telegram from the console (masked token)', async () => {
|
|
|
287
299
|
res = await fetch(`${base}/api/channels/telegram`, {
|
|
288
300
|
method: 'PUT',
|
|
289
301
|
headers: json,
|
|
290
|
-
body: JSON.stringify({ token: '123456:ABCDEFghij' }),
|
|
302
|
+
body: JSON.stringify({ token: '123456:ABCDEFghij', transcribeProvider: 'openai' }),
|
|
291
303
|
});
|
|
292
304
|
data = await res.json();
|
|
293
305
|
assert.equal(data.telegram.tokenSet, true);
|
|
306
|
+
assert.equal(data.telegram.transcribeProvider, 'openai');
|
|
294
307
|
assert.match(data.telegram.tokenHint, /ghij$/);
|
|
295
308
|
assert.doesNotMatch(JSON.stringify(data), /ABCDEF/);
|
|
296
309
|
|
|
@@ -342,6 +355,69 @@ test('manages MCP servers from the console', async () => {
|
|
|
342
355
|
}
|
|
343
356
|
});
|
|
344
357
|
|
|
358
|
+
test('MCP: el env se enmascara y no se pisa al guardar sin cambios', async () => {
|
|
359
|
+
const { instance, base } = await startServer();
|
|
360
|
+
const json = { 'Content-Type': 'application/json' };
|
|
361
|
+
try {
|
|
362
|
+
await fetch(`${base}/api/mcp/servers/db`, {
|
|
363
|
+
method: 'PUT',
|
|
364
|
+
headers: json,
|
|
365
|
+
body: JSON.stringify({
|
|
366
|
+
name: 'DB',
|
|
367
|
+
transport: 'stdio',
|
|
368
|
+
command: 'node',
|
|
369
|
+
args: ['db.mjs'],
|
|
370
|
+
env: { MYSQL_HOST: 'localhost', MYSQL_PASSWORD: 'supersecret' },
|
|
371
|
+
}),
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
let data = await (await fetch(`${base}/api/mcp`)).json();
|
|
375
|
+
const db = data.servers.find((s) => s.id === 'db');
|
|
376
|
+
assert.equal(db.env.MYSQL_HOST, 'localhost');
|
|
377
|
+
assert.equal(db.env.MYSQL_PASSWORD, '••••cret');
|
|
378
|
+
|
|
379
|
+
// Un toggle manda de vuelta el env enmascarado: no debe pisar el secreto.
|
|
380
|
+
await fetch(`${base}/api/mcp/servers/db`, {
|
|
381
|
+
method: 'PUT',
|
|
382
|
+
headers: json,
|
|
383
|
+
body: JSON.stringify({
|
|
384
|
+
name: db.name,
|
|
385
|
+
transport: 'stdio',
|
|
386
|
+
command: db.command,
|
|
387
|
+
args: db.args,
|
|
388
|
+
env: db.env,
|
|
389
|
+
enabled: false,
|
|
390
|
+
}),
|
|
391
|
+
});
|
|
392
|
+
const stored = instance.store.listMcpServers().find((s) => s.id === 'db');
|
|
393
|
+
assert.equal(stored.config.env.MYSQL_PASSWORD, 'supersecret');
|
|
394
|
+
assert.equal(stored.enabled, false);
|
|
395
|
+
} finally {
|
|
396
|
+
await instance.close();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('guarda los límites del agente desde Ajustes', async () => {
|
|
401
|
+
const { instance, base } = await startServer();
|
|
402
|
+
const json = { 'Content-Type': 'application/json' };
|
|
403
|
+
try {
|
|
404
|
+
const res = await fetch(`${base}/api/settings`, {
|
|
405
|
+
method: 'PUT',
|
|
406
|
+
headers: json,
|
|
407
|
+
body: JSON.stringify({ maxRounds: 20, maxTokens: 50000, maxDurationMs: 600000 }),
|
|
408
|
+
});
|
|
409
|
+
const data = await res.json();
|
|
410
|
+
assert.equal(data.settings.maxRounds, 20);
|
|
411
|
+
assert.equal(data.settings.maxTokens, 50000);
|
|
412
|
+
assert.equal(data.settings.maxDurationMs, 600000);
|
|
413
|
+
|
|
414
|
+
const again = await (await fetch(`${base}/api/settings`)).json();
|
|
415
|
+
assert.equal(again.settings.maxRounds, 20);
|
|
416
|
+
} finally {
|
|
417
|
+
await instance.close();
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
345
421
|
test('imports an MCP server from a JSON snippet', async () => {
|
|
346
422
|
const { instance, base } = await startServer();
|
|
347
423
|
const json = { 'Content-Type': 'application/json' };
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
"provider": "openrouter",
|
|
3
3
|
"model": "meta-llama/llama-3.3-8b-instruct:free",
|
|
4
4
|
"instructions": ["AGENTS.md"],
|
|
5
|
-
"maxRounds":
|
|
5
|
+
"maxRounds": 0,
|
|
6
|
+
"maxTokens": 0,
|
|
7
|
+
"maxDurationMs": 0,
|
|
6
8
|
"tools": [],
|
|
7
9
|
"permissions": {},
|
|
8
10
|
"mcp": [
|
|
@@ -14,6 +16,20 @@
|
|
|
14
16
|
"args": ["harness/scripts/mcp-echo-server.mjs"],
|
|
15
17
|
"enabled": true
|
|
16
18
|
},
|
|
19
|
+
{
|
|
20
|
+
"id": "database",
|
|
21
|
+
"name": "DB (ejemplo: credenciales por env, no por parametros)",
|
|
22
|
+
"type": "stdio",
|
|
23
|
+
"command": "node",
|
|
24
|
+
"args": ["ruta/al/servidor-mcp-db.mjs"],
|
|
25
|
+
"env": {
|
|
26
|
+
"MYSQL_HOST": "localhost",
|
|
27
|
+
"MYSQL_USER": "root",
|
|
28
|
+
"MYSQL_PASSWORD": "env:MYSQL_PASSWORD",
|
|
29
|
+
"MYSQL_DATABASE": "app"
|
|
30
|
+
},
|
|
31
|
+
"enabled": false
|
|
32
|
+
},
|
|
17
33
|
{
|
|
18
34
|
"id": "playwright",
|
|
19
35
|
"name": "Playwright (navegador headless, para webs con JS)",
|
package/package.json
CHANGED