@danieltmn/tamnora 0.6.2 → 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 +23 -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 +217 -18
- package/harness/packages/server/public/index.html +89 -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/telegram.js +11 -5
- 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 +76 -1
- package/harness/tamnora.example.json +17 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ 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.7.0] - 2026-10-05
|
|
9
|
+
|
|
10
|
+
### Seguridad
|
|
11
|
+
|
|
12
|
+
- **MCP sin filtracion de entorno**: los servidores stdio ya no heredan
|
|
13
|
+
`process.env`; reciben un baseline minimo mas el `env` declarado (`env:VAR`
|
|
14
|
+
soportado). `inheritEnv: true` es opt-in para servidores de confianza.
|
|
15
|
+
- **Redaccion de secretos**: los resultados MCP pasan por `redactSecrets` y
|
|
16
|
+
`/api/mcp` devuelve el `env` enmascarado.
|
|
17
|
+
|
|
18
|
+
### Cambiado
|
|
19
|
+
|
|
20
|
+
- **Agente autonomo**: `runAgent` corre hasta `end_turn` (sin tope fijo de
|
|
21
|
+
rondas). Frena por **no-progreso** (llamadas repetidas) o por presupuestos
|
|
22
|
+
opcionales `maxTokens`/`maxDurationMs`, y cierra con un resumen
|
|
23
|
+
(`wrappedUp`). La consola agrega el boton **Continuar**.
|
|
24
|
+
- **Ajustes de la consola**: Agente, Sesiones, MCP, Skills, Telegram y
|
|
25
|
+
**Límites** pasan a **pestañas** con ancho acotado (ya no ocupan todo el
|
|
26
|
+
ancho). El formulario MCP suma `inheritEnv` y `env` con `env:VAR`.
|
|
27
|
+
- **Consola**: el footer (modo · proveedor · modelo · estado) ahora es
|
|
28
|
+
clickeable y abre el modal de ajustes; el input muestra un **menú de
|
|
29
|
+
comandos `/`** al escribir la barra.
|
|
30
|
+
|
|
8
31
|
## [0.6.2] - 2026-10-05
|
|
9
32
|
|
|
10
33
|
### Cambios
|
package/SECURITY.md
CHANGED
|
@@ -21,6 +21,12 @@ asi que se lo debe tratar como codigo con acceso a tu usuario.
|
|
|
21
21
|
- **Credenciales**: las API keys de proveedores se guardan en la casa local del
|
|
22
22
|
usuario (fuera del repo) y se cifran con una clave derivada de la passphrase.
|
|
23
23
|
Nunca se commitean y se devuelven enmascaradas al cliente.
|
|
24
|
+
- **Servidores MCP**: tratalos como codigo de terceros. Los MCP stdio **no heredan
|
|
25
|
+
`process.env`** por defecto: solo reciben un baseline minimo mas el `env` que
|
|
26
|
+
declares (`"env": { "TOKEN": "env:TOKEN" }`); `inheritEnv: true` es solo para
|
|
27
|
+
servidores de confianza. Un tool tipo `get-env` no puede leer tus secretos del
|
|
28
|
+
shell, y los resultados MCP se **redactan** (`redactSecrets`) por si devuelven un
|
|
29
|
+
token. No pases credenciales en los argumentos de las tools: van por `env`.
|
|
24
30
|
|
|
25
31
|
## Buenas practicas
|
|
26
32
|
|
|
@@ -106,6 +106,8 @@ export async function runCommand(argv) {
|
|
|
106
106
|
history,
|
|
107
107
|
input: prompt,
|
|
108
108
|
maxRounds: num(values['max-steps'], config.maxRounds),
|
|
109
|
+
maxTokens: config.maxTokens,
|
|
110
|
+
maxDurationMs: config.maxDurationMs,
|
|
109
111
|
signal: controller.signal,
|
|
110
112
|
tools: toolDefs,
|
|
111
113
|
execute,
|
|
@@ -85,6 +85,8 @@ export async function tuiCommand(argv) {
|
|
|
85
85
|
onModeChange: (id) => store.setSetting('activeMode', id),
|
|
86
86
|
systemPrompt: promptFor(mode),
|
|
87
87
|
maxRounds: num(values['max-steps'], config.maxRounds),
|
|
88
|
+
maxTokens: config.maxTokens,
|
|
89
|
+
maxDurationMs: config.maxDurationMs,
|
|
88
90
|
toolOptions: {
|
|
89
91
|
root: cwd,
|
|
90
92
|
enabled: enabledFor(mode),
|
|
@@ -52,6 +52,8 @@ export async function runTurn({
|
|
|
52
52
|
history = [],
|
|
53
53
|
input,
|
|
54
54
|
maxRounds,
|
|
55
|
+
maxTokens,
|
|
56
|
+
maxDurationMs,
|
|
55
57
|
stallWarnMs,
|
|
56
58
|
signal,
|
|
57
59
|
execute,
|
|
@@ -76,6 +78,8 @@ export async function runTurn({
|
|
|
76
78
|
execute,
|
|
77
79
|
toolContext,
|
|
78
80
|
maxRounds,
|
|
81
|
+
maxTokens,
|
|
82
|
+
maxDurationMs,
|
|
79
83
|
stallWarnMs,
|
|
80
84
|
signal,
|
|
81
85
|
onEvent,
|
|
@@ -18,6 +18,8 @@ export function runTui({
|
|
|
18
18
|
onModeChange,
|
|
19
19
|
systemPrompt,
|
|
20
20
|
maxRounds,
|
|
21
|
+
maxTokens,
|
|
22
|
+
maxDurationMs,
|
|
21
23
|
toolOptions,
|
|
22
24
|
store,
|
|
23
25
|
sessionId,
|
|
@@ -170,6 +172,8 @@ export function runTui({
|
|
|
170
172
|
history: conv,
|
|
171
173
|
input: text,
|
|
172
174
|
maxRounds,
|
|
175
|
+
maxTokens,
|
|
176
|
+
maxDurationMs,
|
|
173
177
|
signal: controller.signal,
|
|
174
178
|
tools: toolset.definitions,
|
|
175
179
|
execute: toolset.execute,
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import { withRetry, isRetryableError } from '../providers/resilience.js';
|
|
2
2
|
import { trimContext, estimateTokens } from './context.js';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// 0 = sin limite de rondas: el loop sigue hasta que el modelo deja de pedir
|
|
5
|
+
// herramientas. Los frenos reales son el guard de no-progreso y, opcionalmente,
|
|
6
|
+
// los presupuestos `maxTokens` / `maxDurationMs`.
|
|
7
|
+
export const MAX_TOOL_ROUNDS = 0;
|
|
8
|
+
|
|
9
|
+
/** Rondas seguidas sin ninguna llamada nueva antes de cortar (guard de loop). */
|
|
10
|
+
const NO_PROGRESS_LIMIT = 2;
|
|
11
|
+
|
|
12
|
+
export const WRAP_UP_PROMPT =
|
|
13
|
+
'Se agotó el presupuesto de esta ejecución. No pidas más herramientas: resumí brevemente lo que hiciste, lo que quedó pendiente y respondé al usuario con lo que tengas.';
|
|
14
|
+
|
|
15
|
+
const NO_PROGRESS_MESSAGE =
|
|
16
|
+
'No repitas la misma herramienta con los mismos argumentos: no produce cambios. Usá el resultado anterior o probá otra estrategia.';
|
|
5
17
|
|
|
6
18
|
/**
|
|
7
19
|
* Provider-agnostic agent loop. Runs the model, executes any tool calls it
|
|
8
20
|
* requests, feeds the results back, and repeats until the model answers
|
|
9
|
-
* without tools (or
|
|
21
|
+
* without tools (or a limit/budget stops it).
|
|
10
22
|
*
|
|
11
23
|
* Free of React and browser APIs so it can run headless in Node.
|
|
12
24
|
*
|
|
@@ -20,13 +32,17 @@ export const MAX_TOOL_ROUNDS = 8;
|
|
|
20
32
|
* @param {object} [opts.toolContext] - passed through to `execute`
|
|
21
33
|
* @param {AbortSignal} [opts.signal]
|
|
22
34
|
* @param {Function} [opts.onEvent] - streaming/lifecycle callback
|
|
23
|
-
* @param {number} [opts.maxRounds]
|
|
35
|
+
* @param {number} [opts.maxRounds] - techo de seguridad de rondas (0 = sin limite)
|
|
36
|
+
* @param {number} [opts.maxTokens] - presupuesto de tokens totales (0 = sin limite)
|
|
37
|
+
* @param {number} [opts.maxDurationMs] - presupuesto de tiempo (0 = sin limite)
|
|
38
|
+
* @param {number} [opts.repeatLimit] - veces que una misma llamada puede repetirse (0 = desactivado)
|
|
39
|
+
* @param {boolean} [opts.wrapUp] - ronda final sin tools para resumir si se cortó
|
|
24
40
|
* @param {number} [opts.stallWarnMs] - warn once if the model emits nothing for
|
|
25
41
|
* this long (0 disables; default 90000)
|
|
26
42
|
* @param {number} [opts.retries] - reintentos por ronda ante error transitorio
|
|
27
43
|
* (default 2; solo si la ronda no emitio contenido todavia)
|
|
28
44
|
* @param {number} [opts.retryDelayMs] - base del backoff (default 500)
|
|
29
|
-
* @returns {Promise<{content, reasoning, toolCalls, messages, rounds, stopReason, usage}>}
|
|
45
|
+
* @returns {Promise<{content, reasoning, toolCalls, messages, rounds, stopReason, usage, wrappedUp}>}
|
|
30
46
|
*/
|
|
31
47
|
export async function runAgent({
|
|
32
48
|
provider,
|
|
@@ -39,6 +55,10 @@ export async function runAgent({
|
|
|
39
55
|
signal,
|
|
40
56
|
onEvent,
|
|
41
57
|
maxRounds = MAX_TOOL_ROUNDS,
|
|
58
|
+
maxTokens = 0,
|
|
59
|
+
maxDurationMs = 0,
|
|
60
|
+
repeatLimit = 3,
|
|
61
|
+
wrapUp = true,
|
|
42
62
|
stallWarnMs = 90000,
|
|
43
63
|
retries = 2,
|
|
44
64
|
retryDelayMs = 500,
|
|
@@ -46,18 +66,38 @@ export async function runAgent({
|
|
|
46
66
|
}) {
|
|
47
67
|
const working = [...messages];
|
|
48
68
|
let usageTotal = null;
|
|
69
|
+
const startedAt = Date.now();
|
|
70
|
+
const fingerprints = new Map();
|
|
71
|
+
const unlimited = !(maxRounds > 0);
|
|
72
|
+
let noProgressStreak = 0;
|
|
73
|
+
let roundsRun = 0;
|
|
74
|
+
let stopReason = 'max_rounds';
|
|
75
|
+
let content = '';
|
|
76
|
+
|
|
77
|
+
const accumulateUsage = (roundUsage) => {
|
|
78
|
+
if (!roundUsage) return {};
|
|
79
|
+
usageTotal = {
|
|
80
|
+
prompt_tokens: (usageTotal?.prompt_tokens ?? 0) + roundUsage.prompt_tokens,
|
|
81
|
+
completion_tokens: (usageTotal?.completion_tokens ?? 0) + roundUsage.completion_tokens,
|
|
82
|
+
total_tokens: (usageTotal?.total_tokens ?? 0) + roundUsage.total_tokens,
|
|
83
|
+
// last round = current context footprint (for context meters)
|
|
84
|
+
last_prompt_tokens: roundUsage.prompt_tokens,
|
|
85
|
+
last_completion_tokens: roundUsage.completion_tokens,
|
|
86
|
+
};
|
|
87
|
+
return { tokens_in: roundUsage.prompt_tokens, tokens_out: roundUsage.completion_tokens };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const budgetExceeded = () => {
|
|
91
|
+
if (maxDurationMs > 0 && Date.now() - startedAt >= maxDurationMs) return 'duration';
|
|
92
|
+
if (maxTokens > 0 && (usageTotal?.total_tokens ?? 0) >= maxTokens) return 'tokens';
|
|
93
|
+
return null;
|
|
94
|
+
};
|
|
49
95
|
|
|
50
|
-
|
|
96
|
+
while (unlimited || roundsRun < maxRounds) {
|
|
97
|
+
roundsRun++;
|
|
51
98
|
if (signal?.aborted) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
reasoning: '',
|
|
55
|
-
toolCalls: [],
|
|
56
|
-
messages: working,
|
|
57
|
-
rounds: Math.max(0, round - 1),
|
|
58
|
-
stopReason: 'aborted',
|
|
59
|
-
usage: usageTotal,
|
|
60
|
-
};
|
|
99
|
+
stopReason = 'aborted';
|
|
100
|
+
break;
|
|
61
101
|
}
|
|
62
102
|
|
|
63
103
|
const stall = createStallGuard(onEvent, stallWarnMs);
|
|
@@ -109,38 +149,20 @@ export async function runAgent({
|
|
|
109
149
|
stall.dispose();
|
|
110
150
|
}
|
|
111
151
|
|
|
112
|
-
const
|
|
113
|
-
if (roundUsage) {
|
|
114
|
-
usageTotal = {
|
|
115
|
-
prompt_tokens: (usageTotal?.prompt_tokens ?? 0) + roundUsage.prompt_tokens,
|
|
116
|
-
completion_tokens: (usageTotal?.completion_tokens ?? 0) + roundUsage.completion_tokens,
|
|
117
|
-
total_tokens: (usageTotal?.total_tokens ?? 0) + roundUsage.total_tokens,
|
|
118
|
-
// last round = current context footprint (for context meters)
|
|
119
|
-
last_prompt_tokens: roundUsage.prompt_tokens,
|
|
120
|
-
last_completion_tokens: roundUsage.completion_tokens,
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
const tokens = roundUsage
|
|
124
|
-
? { tokens_in: roundUsage.prompt_tokens, tokens_out: roundUsage.completion_tokens }
|
|
125
|
-
: {};
|
|
152
|
+
const tokens = accumulateUsage(result.usage ?? null);
|
|
126
153
|
|
|
127
154
|
const calls = result.toolCalls ?? [];
|
|
128
155
|
if (calls.length === 0) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
toolCalls: [],
|
|
134
|
-
messages: working,
|
|
135
|
-
rounds: round,
|
|
136
|
-
stopReason: 'end_turn',
|
|
137
|
-
usage: usageTotal,
|
|
138
|
-
};
|
|
156
|
+
content = result.content ?? '';
|
|
157
|
+
working.push({ role: 'assistant', content, ...tokens });
|
|
158
|
+
stopReason = 'end_turn';
|
|
159
|
+
break;
|
|
139
160
|
}
|
|
140
161
|
|
|
141
162
|
working.push({ role: 'assistant', content: result.content || null, tool_calls: calls, ...tokens });
|
|
142
|
-
onEvent?.({ type: 'tool_round', round, toolCalls: calls, content: result.content });
|
|
163
|
+
onEvent?.({ type: 'tool_round', round: roundsRun, toolCalls: calls, content: result.content });
|
|
143
164
|
|
|
165
|
+
let newCalls = 0;
|
|
144
166
|
for (let index = 0; index < calls.length; index++) {
|
|
145
167
|
const call = calls[index];
|
|
146
168
|
const name = call.function?.name ?? call.name;
|
|
@@ -162,20 +184,32 @@ export async function runAgent({
|
|
|
162
184
|
reasoning: result.reasoning ?? '',
|
|
163
185
|
toolCalls: calls,
|
|
164
186
|
messages: working,
|
|
165
|
-
rounds:
|
|
187
|
+
rounds: roundsRun,
|
|
166
188
|
stopReason: 'aborted',
|
|
167
189
|
usage: usageTotal,
|
|
190
|
+
wrappedUp: false,
|
|
168
191
|
};
|
|
169
192
|
}
|
|
170
193
|
|
|
171
194
|
const args = parseToolArguments(call);
|
|
172
195
|
onEvent?.({ type: 'tool_call', id: call.id, name, args });
|
|
173
196
|
|
|
197
|
+
const fingerprint = `${name}\u0000${stableStringify(args)}`;
|
|
198
|
+
const seen = (fingerprints.get(fingerprint) ?? 0) + 1;
|
|
199
|
+
fingerprints.set(fingerprint, seen);
|
|
200
|
+
|
|
174
201
|
let output;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
output =
|
|
202
|
+
if (repeatLimit > 0 && seen > repeatLimit) {
|
|
203
|
+
// Llamada identica repetida: no re-ejecutar, avisar al modelo.
|
|
204
|
+
onEvent?.({ type: 'repeat', name, count: seen });
|
|
205
|
+
output = NO_PROGRESS_MESSAGE;
|
|
206
|
+
} else {
|
|
207
|
+
newCalls++;
|
|
208
|
+
try {
|
|
209
|
+
output = await execute(name, args, toolContext);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
output = `Error ejecutando ${name}: ${err?.message ?? err}`;
|
|
212
|
+
}
|
|
179
213
|
}
|
|
180
214
|
|
|
181
215
|
onEvent?.({ type: 'tool_result', id: call.id, name, result: output });
|
|
@@ -186,19 +220,69 @@ export async function runAgent({
|
|
|
186
220
|
content: typeof output === 'string' ? output : JSON.stringify(output),
|
|
187
221
|
});
|
|
188
222
|
}
|
|
223
|
+
|
|
224
|
+
noProgressStreak = newCalls === 0 ? noProgressStreak + 1 : 0;
|
|
225
|
+
if (noProgressStreak >= NO_PROGRESS_LIMIT) {
|
|
226
|
+
stopReason = 'no_progress';
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
if (budgetExceeded()) {
|
|
230
|
+
stopReason = 'budget';
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let wrappedUp = false;
|
|
236
|
+
if (wrapUp && (stopReason === 'max_rounds' || stopReason === 'no_progress' || stopReason === 'budget')) {
|
|
237
|
+
const wrap = await runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries: retries, retryDelayMs });
|
|
238
|
+
if (wrap.usage) accumulateUsage(wrap.usage);
|
|
239
|
+
if (wrap.content) {
|
|
240
|
+
working.push({ role: 'assistant', content: wrap.content, tokens_in: wrap.usage?.prompt_tokens, tokens_out: wrap.usage?.completion_tokens });
|
|
241
|
+
content = wrap.content;
|
|
242
|
+
wrappedUp = true;
|
|
243
|
+
}
|
|
189
244
|
}
|
|
190
245
|
|
|
191
246
|
return {
|
|
192
|
-
content
|
|
247
|
+
content,
|
|
193
248
|
reasoning: '',
|
|
194
249
|
toolCalls: [],
|
|
195
250
|
messages: working,
|
|
196
|
-
rounds:
|
|
197
|
-
stopReason
|
|
251
|
+
rounds: roundsRun,
|
|
252
|
+
stopReason,
|
|
198
253
|
usage: usageTotal,
|
|
254
|
+
wrappedUp,
|
|
199
255
|
};
|
|
200
256
|
}
|
|
201
257
|
|
|
258
|
+
/** Ronda final con tools deshabilitadas para forzar un cierre con texto. */
|
|
259
|
+
async function runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries, retryDelayMs }) {
|
|
260
|
+
try {
|
|
261
|
+
const result = await withRetry(
|
|
262
|
+
() =>
|
|
263
|
+
provider.streamChat(config, {
|
|
264
|
+
model,
|
|
265
|
+
messages: [...working, { role: 'system', content: WRAP_UP_PROMPT }],
|
|
266
|
+
tools: [],
|
|
267
|
+
signal,
|
|
268
|
+
onEvent,
|
|
269
|
+
}),
|
|
270
|
+
{ retries: maxRetries, baseDelayMs: retryDelayMs, signal }
|
|
271
|
+
);
|
|
272
|
+
return { content: result?.content ?? '', usage: result?.usage ?? null };
|
|
273
|
+
} catch {
|
|
274
|
+
return { content: '', usage: null };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** JSON estable (claves ordenadas) para huellar argumentos. */
|
|
279
|
+
function stableStringify(value) {
|
|
280
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
281
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
282
|
+
const keys = Object.keys(value).sort();
|
|
283
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
202
286
|
/**
|
|
203
287
|
* Emit a single warning event when the model emits nothing for `stallWarnMs`
|
|
204
288
|
* during one stream (openbridge's stall watchdog port). Any provider event
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Secret indirection helpers. Values may reference environment variables as
|
|
2
|
+
// `env:VAR` so credentials never live in `tamnora.json` or in tool arguments.
|
|
3
|
+
|
|
4
|
+
import { getEnv } from '../env.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a single value that may be an `env:VAR` reference.
|
|
8
|
+
* @param {string} value
|
|
9
|
+
* @param {Record<string,string>} [env]
|
|
10
|
+
*/
|
|
11
|
+
export function resolveApiKey(value, env = getEnv()) {
|
|
12
|
+
if (typeof value === 'string' && value.startsWith('env:')) {
|
|
13
|
+
return env[value.slice(4)] ?? '';
|
|
14
|
+
}
|
|
15
|
+
return value ?? '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the values of an env map, expanding `env:VAR` references.
|
|
20
|
+
* String values only; anything else is stringified.
|
|
21
|
+
* @param {Record<string, unknown>} map
|
|
22
|
+
* @param {Record<string,string>} [env]
|
|
23
|
+
*/
|
|
24
|
+
export function resolveEnvMap(map = {}, env = getEnv()) {
|
|
25
|
+
const result = {};
|
|
26
|
+
for (const [key, value] of Object.entries(map ?? {})) {
|
|
27
|
+
if (value == null) continue;
|
|
28
|
+
result[key] = typeof value === 'string' ? resolveApiKey(value, env) : String(value);
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** True when at least one value in the map is an unresolved `env:VAR` reference. */
|
|
34
|
+
export function hasEnvRefs(map = {}) {
|
|
35
|
+
return Object.values(map ?? {}).some((value) => typeof value === 'string' && value.startsWith('env:'));
|
|
36
|
+
}
|
|
@@ -1,18 +1,9 @@
|
|
|
1
1
|
import { resolveProviderRuntime } from '../providers/index.js';
|
|
2
2
|
import { runAgent } from '../agent/runAgent.js';
|
|
3
3
|
import { buildMcpRegistry } from '../mcp/index.js';
|
|
4
|
+
import { resolveApiKey } from '../config/secrets.js';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
* Resolve `env:VAR` references in a task's apiKey field.
|
|
7
|
-
* @param {string} value
|
|
8
|
-
* @param {Record<string,string>} env
|
|
9
|
-
*/
|
|
10
|
-
export function resolveApiKey(value, env = {}) {
|
|
11
|
-
if (typeof value === 'string' && value.startsWith('env:')) {
|
|
12
|
-
return env[value.slice(4)] ?? '';
|
|
13
|
-
}
|
|
14
|
-
return value ?? '';
|
|
15
|
-
}
|
|
6
|
+
export { resolveApiKey } from '../config/secrets.js';
|
|
16
7
|
|
|
17
8
|
/**
|
|
18
9
|
* Run a single evaluation task headlessly.
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
export { getEnv, envValue } from './env.js';
|
|
2
|
+
export {
|
|
3
|
+
redactSecrets,
|
|
4
|
+
redactDeep,
|
|
5
|
+
secretValues,
|
|
6
|
+
isSecretName,
|
|
7
|
+
maskSecret,
|
|
8
|
+
maskEnv,
|
|
9
|
+
REDACTED,
|
|
10
|
+
} from './redact.js';
|
|
11
|
+
export { resolveApiKey, resolveEnvMap } from './config/secrets.js';
|
|
2
12
|
export { ensureUtf8Console } from './console.js';
|
|
3
13
|
export { MAX_TOOL_ROUNDS, runAgent, parseToolArguments } from './agent/runAgent.js';
|
|
4
14
|
export { estimateTokens, trimContext } from './agent/context.js';
|
|
@@ -20,10 +30,12 @@ export {
|
|
|
20
30
|
connectMcpServer,
|
|
21
31
|
connectMcpHttpServer,
|
|
22
32
|
connectMcpStdioServer,
|
|
33
|
+
childEnv,
|
|
23
34
|
buildMcpRegistry,
|
|
24
35
|
mcpTransport,
|
|
25
36
|
qualifiedToolName,
|
|
26
37
|
mcpToolToDefinition,
|
|
38
|
+
mcpResultToText,
|
|
27
39
|
} from './mcp/index.js';
|
|
28
40
|
export {
|
|
29
41
|
loadConfig,
|
|
@@ -65,7 +77,7 @@ export {
|
|
|
65
77
|
toRel,
|
|
66
78
|
} from './tools/index.js';
|
|
67
79
|
export { Store, openDatabase } from './storage/sqlite.js';
|
|
68
|
-
export { runTask
|
|
80
|
+
export { runTask } from './eval/runTask.js';
|
|
69
81
|
export { loadDataset } from './eval/dataset.js';
|
|
70
82
|
export { runDataset, extractToolCalls } from './eval/runner.js';
|
|
71
83
|
export { summarize } from './eval/report.js';
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Shared MCP helpers used by both transports (HTTP and stdio).
|
|
2
2
|
|
|
3
|
+
import { redactSecrets } from '../redact.js';
|
|
4
|
+
|
|
3
5
|
export const PROTOCOL_VERSION = '2025-06-18';
|
|
4
6
|
export const CLIENT_INFO = { name: 'tamnora', version: '1.0.0' };
|
|
5
7
|
|
|
@@ -24,27 +26,29 @@ export function mcpToolToDefinition(tool, server, qualifiedName) {
|
|
|
24
26
|
/** Flatten an MCP `tools/call` result into plain text for the model. */
|
|
25
27
|
export function mcpResultToText(result) {
|
|
26
28
|
if (!result) return '';
|
|
27
|
-
if (typeof result.content === 'string') return result.content;
|
|
29
|
+
if (typeof result.content === 'string') return redactSecrets(result.content);
|
|
28
30
|
if (Array.isArray(result.content)) {
|
|
29
31
|
const text = result.content
|
|
30
32
|
.map((c) => (c.type === 'text' ? c.text : `[${c.type}]`))
|
|
31
33
|
.join('\n');
|
|
32
|
-
return result.isError ? `Error: ${text}` : text;
|
|
34
|
+
return redactSecrets(result.isError ? `Error: ${text}` : text);
|
|
33
35
|
}
|
|
34
|
-
return JSON.stringify(result);
|
|
36
|
+
return redactSecrets(JSON.stringify(result));
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/** Flatten a `resources/read` result into plain text. */
|
|
38
40
|
export function mcpResourceToText(result) {
|
|
39
41
|
const contents = result?.contents ?? [];
|
|
40
42
|
if (!contents.length) return '(recurso vacío)';
|
|
41
|
-
return
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
return redactSecrets(
|
|
44
|
+
contents
|
|
45
|
+
.map((c) => {
|
|
46
|
+
if (typeof c.text === 'string') return c.text;
|
|
47
|
+
if (c.blob) return `[binario ${c.mimeType ?? ''} ${c.blob.length}b64]`;
|
|
48
|
+
return JSON.stringify(c);
|
|
49
|
+
})
|
|
50
|
+
.join('\n')
|
|
51
|
+
);
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/** Flatten a `prompts/get` result into plain text. */
|
|
@@ -59,7 +63,7 @@ export function mcpPromptToText(result) {
|
|
|
59
63
|
: (message.content?.text ?? JSON.stringify(message.content));
|
|
60
64
|
parts.push(`[${message.role}] ${content}`);
|
|
61
65
|
}
|
|
62
|
-
return parts.join('\n') || '(prompt vacío)';
|
|
66
|
+
return redactSecrets(parts.join('\n')) || '(prompt vacío)';
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
/** Text summary of a `resources/list` result. */
|
|
@@ -112,6 +112,7 @@ export function normalizeMcpConfig(input, fallbackId = 'mcp') {
|
|
|
112
112
|
args: url ? [] : args,
|
|
113
113
|
url: url ? String(url) : undefined,
|
|
114
114
|
env: def.env && typeof def.env === 'object' ? def.env : undefined,
|
|
115
|
+
inheritEnv: def.inheritEnv === true,
|
|
115
116
|
enabled: def.enabled !== false,
|
|
116
117
|
};
|
|
117
118
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
mcpResourceToText,
|
|
12
12
|
mcpPromptToText,
|
|
13
13
|
} from './common.js';
|
|
14
|
+
import { resolveEnvMap } from '../config/secrets.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* On Windows, bare commands like `npx`/`uvx` are `.cmd` shims that require a
|
|
@@ -25,10 +26,48 @@ function shouldUseShell(server, command) {
|
|
|
25
26
|
return true;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
// Variables minimas para que un proceso hijo comun (node, npx, uvx) arranque.
|
|
30
|
+
const BASE_ENV_KEYS = [
|
|
31
|
+
'PATH',
|
|
32
|
+
'PATHEXT',
|
|
33
|
+
'SystemRoot',
|
|
34
|
+
'windir',
|
|
35
|
+
'ComSpec',
|
|
36
|
+
'TEMP',
|
|
37
|
+
'TMP',
|
|
38
|
+
'TMPDIR',
|
|
39
|
+
'USERPROFILE',
|
|
40
|
+
'HOME',
|
|
41
|
+
'APPDATA',
|
|
42
|
+
'LOCALAPPDATA',
|
|
43
|
+
'LANG',
|
|
44
|
+
'LC_ALL',
|
|
45
|
+
'TZ',
|
|
46
|
+
'NUMBER_OF_PROCESSORS',
|
|
47
|
+
'PROCESSOR_ARCHITECTURE',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Construye el entorno del hijo MCP. Por defecto **no** hereda `process.env`
|
|
52
|
+
* (asi un MCP de terceros no puede leer tokens del shell); solo pasa un
|
|
53
|
+
* baseline seguro mas el `env` declarado. `inheritEnv: true` restaura la
|
|
54
|
+
* herencia completa para servidores de confianza.
|
|
55
|
+
*/
|
|
56
|
+
export function childEnv(server = {}) {
|
|
57
|
+
const declared = resolveEnvMap(server.env ?? {});
|
|
58
|
+
if (server.inheritEnv === true) return { ...process.env, ...declared };
|
|
59
|
+
|
|
60
|
+
const base = {};
|
|
61
|
+
for (const key of BASE_ENV_KEYS) {
|
|
62
|
+
if (process.env[key] !== undefined) base[key] = process.env[key];
|
|
63
|
+
}
|
|
64
|
+
return { ...base, ...declared };
|
|
65
|
+
}
|
|
66
|
+
|
|
28
67
|
/**
|
|
29
68
|
* Launch an MCP stdio server, initialize it and list its tools.
|
|
30
69
|
*
|
|
31
|
-
* @param {{id:string, name?:string, command:string, args?:string[], env?:object, cwd?:string, shell?:boolean}} server
|
|
70
|
+
* @param {{id:string, name?:string, command:string, args?:string[], env?:object, inheritEnv?:boolean, cwd?:string, shell?:boolean}} server
|
|
32
71
|
* @param {{signal?:AbortSignal, timeout?:number}} [opts]
|
|
33
72
|
*/
|
|
34
73
|
export async function connectMcpStdioServer(server, { signal, timeout = 30000 } = {}) {
|
|
@@ -37,7 +76,7 @@ export async function connectMcpStdioServer(server, { signal, timeout = 30000 }
|
|
|
37
76
|
|
|
38
77
|
const child = spawn(command, server.args ?? [], {
|
|
39
78
|
cwd: server.cwd,
|
|
40
|
-
env:
|
|
79
|
+
env: childEnv(server),
|
|
41
80
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
42
81
|
windowsHide: true,
|
|
43
82
|
shell: shouldUseShell(server, command),
|