@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
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
</head>
|
|
34
34
|
<body>
|
|
35
35
|
<header class="topbar">
|
|
36
|
-
<div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.
|
|
36
|
+
<div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.7.0</span></div>
|
|
37
37
|
<nav class="tabs">
|
|
38
38
|
<button class="tab active" data-tab="chat">chat</button>
|
|
39
39
|
<button class="tab" data-tab="providers">proveedores</button>
|
|
@@ -118,6 +118,7 @@
|
|
|
118
118
|
</div>
|
|
119
119
|
</div>
|
|
120
120
|
<form id="chat-form" class="composer">
|
|
121
|
+
<div id="slash-menu" class="slash-menu" hidden></div>
|
|
121
122
|
<div id="attach-preview" class="attach-preview" hidden></div>
|
|
122
123
|
<div class="composer-row">
|
|
123
124
|
<span class="prompt-mark">❯</span>
|
|
@@ -134,7 +135,7 @@
|
|
|
134
135
|
<button class="btn" type="button" id="stop-btn" title="Detener la respuesta" hidden>parar</button>
|
|
135
136
|
</div>
|
|
136
137
|
</form>
|
|
137
|
-
<div class="statusbar">
|
|
138
|
+
<div id="statusbar" class="statusbar" title="cambiar modo, proveedor y modelo" role="button" tabindex="0">
|
|
138
139
|
<span id="sb-mode">agent</span>
|
|
139
140
|
<span class="dot">·</span>
|
|
140
141
|
<span id="sb-provider">—</span>
|
|
@@ -343,7 +344,62 @@
|
|
|
343
344
|
</section>
|
|
344
345
|
|
|
345
346
|
<section id="view-ajustes" class="view">
|
|
346
|
-
<div class="
|
|
347
|
+
<div class="ajustes-wrap">
|
|
348
|
+
<nav class="ajustes-tabs" role="tablist" aria-label="secciones de ajustes">
|
|
349
|
+
<button type="button" class="ajustes-tab active" data-panel="agente" role="tab">Agente</button>
|
|
350
|
+
<button type="button" class="ajustes-tab" data-panel="sesiones" role="tab">Sesiones</button>
|
|
351
|
+
<button type="button" class="ajustes-tab" data-panel="mcp" role="tab">MCP</button>
|
|
352
|
+
<button type="button" class="ajustes-tab" data-panel="skills" role="tab">Skills</button>
|
|
353
|
+
<button type="button" class="ajustes-tab" data-panel="telegram" role="tab">Telegram</button>
|
|
354
|
+
<button type="button" class="ajustes-tab" data-panel="limites" role="tab">Límites</button>
|
|
355
|
+
</nav>
|
|
356
|
+
|
|
357
|
+
<div class="panel ajustes-panel active" data-panel="agente">
|
|
358
|
+
<div class="row between">
|
|
359
|
+
<h2>Agente</h2>
|
|
360
|
+
</div>
|
|
361
|
+
<p class="muted verify-line">
|
|
362
|
+
Modo, proveedor, modelo y aprobación automática del agente.
|
|
363
|
+
</p>
|
|
364
|
+
<div id="agent-controls-slot">
|
|
365
|
+
<div id="agent-settings-controls">
|
|
366
|
+
<label for="mode">Modo</label>
|
|
367
|
+
<select id="mode" title="modo de agente"></select>
|
|
368
|
+
<label for="provider">Proveedor</label>
|
|
369
|
+
<select id="provider" title="proveedor"></select>
|
|
370
|
+
<label for="model">Modelo</label>
|
|
371
|
+
<select id="model" title="modelo"></select>
|
|
372
|
+
<label class="check" title="aprobar automáticamente tools con permiso ask">
|
|
373
|
+
<input id="yes" type="checkbox" /> Aprobar automáticamente las tools (auto)
|
|
374
|
+
</label>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
<div class="row">
|
|
378
|
+
<button type="button" id="agent-settings-open" class="btn">abrir en ventana</button>
|
|
379
|
+
<button type="button" id="agent-providers" class="btn">gestionar proveedores</button>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
<div class="panel ajustes-panel" data-panel="sesiones">
|
|
384
|
+
<div class="row between">
|
|
385
|
+
<h2>Sesiones</h2>
|
|
386
|
+
</div>
|
|
387
|
+
<p class="muted verify-line">
|
|
388
|
+
Carpeta donde se abre el selector de carpetas y <code>/new</code> cuando no hay sesión.
|
|
389
|
+
</p>
|
|
390
|
+
<label for="default-cwd">Carpeta predeterminada para nuevas sesiones</label>
|
|
391
|
+
<div class="row default-cwd-row">
|
|
392
|
+
<input id="default-cwd" type="text" readonly placeholder="(la casa del usuario)" title="carpeta donde se abre el selector y /new sin sesión" />
|
|
393
|
+
<button type="button" id="default-cwd-pick" class="btn">elegir</button>
|
|
394
|
+
<button type="button" id="default-cwd-clear" class="btn icon" title="quitar la carpeta predeterminada" aria-label="quitar la carpeta predeterminada">
|
|
395
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
396
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
397
|
+
</svg>
|
|
398
|
+
</button>
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
<div class="panel ajustes-panel" data-panel="mcp">
|
|
347
403
|
<div class="row between">
|
|
348
404
|
<h2>Servidores MCP</h2>
|
|
349
405
|
<div class="row">
|
|
@@ -362,7 +418,7 @@
|
|
|
362
418
|
<ul id="mcp-list" class="providers"></ul>
|
|
363
419
|
</div>
|
|
364
420
|
|
|
365
|
-
<div class="panel">
|
|
421
|
+
<div class="panel ajustes-panel" data-panel="skills">
|
|
366
422
|
<div class="row between">
|
|
367
423
|
<h2>Skills</h2>
|
|
368
424
|
<button id="skill-new" class="btn">+ nueva</button>
|
|
@@ -378,7 +434,7 @@
|
|
|
378
434
|
<ul id="skill-list" class="providers"></ul>
|
|
379
435
|
</div>
|
|
380
436
|
|
|
381
|
-
<div class="panel">
|
|
437
|
+
<div class="panel ajustes-panel" data-panel="telegram">
|
|
382
438
|
<div class="row between">
|
|
383
439
|
<h2>Telegram</h2>
|
|
384
440
|
<span id="tg-status" class="summary">cargando…</span>
|
|
@@ -416,6 +472,30 @@
|
|
|
416
472
|
<span id="tg-msg" class="summary"></span>
|
|
417
473
|
</div>
|
|
418
474
|
</div>
|
|
475
|
+
|
|
476
|
+
<div class="panel ajustes-panel" data-panel="limites">
|
|
477
|
+
<div class="row between">
|
|
478
|
+
<h2>Límites del agente</h2>
|
|
479
|
+
</div>
|
|
480
|
+
<p class="muted verify-line">
|
|
481
|
+
El agente trabaja hasta terminar solo (<code>end_turn</code>). Estos topes son
|
|
482
|
+
opcionales: <code>0</code> = sin límite. Al cortarse, cierra con un resumen.
|
|
483
|
+
</p>
|
|
484
|
+
<label class="field"><span>máximo de rondas de herramientas (0 = sin límite)</span>
|
|
485
|
+
<input id="limits-rounds" type="number" min="0" step="1" placeholder="0" />
|
|
486
|
+
</label>
|
|
487
|
+
<label class="field"><span>presupuesto de tokens (0 = sin límite)</span>
|
|
488
|
+
<input id="limits-tokens" type="number" min="0" step="1000" placeholder="0" />
|
|
489
|
+
</label>
|
|
490
|
+
<label class="field"><span>presupuesto de tiempo en segundos (0 = sin límite)</span>
|
|
491
|
+
<input id="limits-duration" type="number" min="0" step="1" placeholder="0" />
|
|
492
|
+
</label>
|
|
493
|
+
<div class="row">
|
|
494
|
+
<button id="limits-save" class="btn primary" type="button">guardar</button>
|
|
495
|
+
<span id="limits-msg" class="summary"></span>
|
|
496
|
+
</div>
|
|
497
|
+
</div>
|
|
498
|
+
</div>
|
|
419
499
|
</section>
|
|
420
500
|
|
|
421
501
|
<dialog id="folder-modal" class="modal folder-modal">
|
|
@@ -485,25 +565,7 @@
|
|
|
485
565
|
</svg>
|
|
486
566
|
</button>
|
|
487
567
|
</div>
|
|
488
|
-
<
|
|
489
|
-
<select id="mode" title="modo de agente"></select>
|
|
490
|
-
<label for="provider">Proveedor</label>
|
|
491
|
-
<select id="provider" title="proveedor"></select>
|
|
492
|
-
<label for="model">Modelo</label>
|
|
493
|
-
<select id="model" title="modelo"></select>
|
|
494
|
-
<label for="default-cwd">Carpeta predeterminada para nuevas sesiones</label>
|
|
495
|
-
<div class="row default-cwd-row">
|
|
496
|
-
<input id="default-cwd" type="text" readonly placeholder="(la casa del usuario)" title="carpeta donde se abre el selector y /new sin sesión" />
|
|
497
|
-
<button type="button" id="default-cwd-pick" class="btn">elegir</button>
|
|
498
|
-
<button type="button" id="default-cwd-clear" class="btn icon" title="quitar la carpeta predeterminada" aria-label="quitar la carpeta predeterminada">
|
|
499
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
500
|
-
<path d="M18 6 6 18M6 6l12 12" />
|
|
501
|
-
</svg>
|
|
502
|
-
</button>
|
|
503
|
-
</div>
|
|
504
|
-
<label class="check" title="aprobar automáticamente tools con permiso ask">
|
|
505
|
-
<input id="yes" type="checkbox" /> Aprobar automáticamente las tools (auto)
|
|
506
|
-
</label>
|
|
568
|
+
<div id="settings-controls-slot"></div>
|
|
507
569
|
<div class="row between settings-actions">
|
|
508
570
|
<button type="button" id="settings-providers" class="btn">gestionar proveedores</button>
|
|
509
571
|
<button type="button" id="settings-done" class="btn primary">listo</button>
|
|
@@ -538,9 +600,10 @@
|
|
|
538
600
|
<div class="row">
|
|
539
601
|
<label class="field grow" id="mf-args-field"><span>args (separados por espacio)</span><input id="mf-args" spellcheck="false" placeholder="harness/scripts/mcp-echo-server.mjs" /></label>
|
|
540
602
|
</div>
|
|
541
|
-
<label class="field"><span>env (KEY=valor por línea)</span>
|
|
542
|
-
<textarea id="mf-env" rows="3" spellcheck="false" placeholder="API_KEY
|
|
603
|
+
<label class="field" id="mf-env-field"><span>env (KEY=valor por línea · usá <code>env:VAR</code> para no hardcodear secretos)</span>
|
|
604
|
+
<textarea id="mf-env" rows="3" spellcheck="false" placeholder="API_KEY=env:MY_API_KEY"></textarea>
|
|
543
605
|
</label>
|
|
606
|
+
<label class="check" id="mf-inherit-field"><input id="mf-inherit-env" type="checkbox" /> heredar todo el entorno (<code>inheritEnv</code>, solo MCPs de confianza)</label>
|
|
544
607
|
<label class="check"><input id="mf-enabled" type="checkbox" checked /> habilitado</label>
|
|
545
608
|
<div class="row between settings-actions">
|
|
546
609
|
<button type="button" id="mf-cancel" class="btn">cancelar</button>
|
|
@@ -621,7 +621,7 @@ details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top
|
|
|
621
621
|
|
|
622
622
|
@keyframes pulse { 50% { opacity: 0.35; } }
|
|
623
623
|
|
|
624
|
-
.composer { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-top: 1px solid var(--border); background: var(--panel); backdrop-filter: blur(14px); }
|
|
624
|
+
.composer { position: relative; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-top: 1px solid var(--border); background: var(--panel); backdrop-filter: blur(14px); }
|
|
625
625
|
.composer.dragging { outline: 1px dashed var(--accent); outline-offset: -4px; }
|
|
626
626
|
.composer-row { display: flex; align-items: flex-end; gap: 8px; }
|
|
627
627
|
.prompt-mark { color: var(--mine); font-weight: 700; padding-bottom: 8px; }
|
|
@@ -707,6 +707,46 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
|
|
|
707
707
|
}
|
|
708
708
|
.statusbar .dot { opacity: 0.5; }
|
|
709
709
|
.statusbar .spacer { flex: 1; }
|
|
710
|
+
.statusbar[role="button"] { cursor: pointer; }
|
|
711
|
+
.statusbar[role="button"]:hover { color: var(--fg); }
|
|
712
|
+
|
|
713
|
+
/* menú de comandos "/" en el composer */
|
|
714
|
+
.slash-menu {
|
|
715
|
+
position: absolute;
|
|
716
|
+
left: 14px;
|
|
717
|
+
right: 14px;
|
|
718
|
+
bottom: calc(100% - 6px);
|
|
719
|
+
max-height: 260px;
|
|
720
|
+
overflow-y: auto;
|
|
721
|
+
display: flex;
|
|
722
|
+
flex-direction: column;
|
|
723
|
+
gap: 2px;
|
|
724
|
+
padding: 6px;
|
|
725
|
+
border: 1px solid var(--border);
|
|
726
|
+
border-radius: 12px;
|
|
727
|
+
background: var(--panel);
|
|
728
|
+
box-shadow: 0 12px 32px rgb(0 0 0 / 0.22);
|
|
729
|
+
z-index: 30;
|
|
730
|
+
}
|
|
731
|
+
.slash-menu[hidden] { display: none; }
|
|
732
|
+
.slash-item {
|
|
733
|
+
display: flex;
|
|
734
|
+
align-items: baseline;
|
|
735
|
+
gap: 10px;
|
|
736
|
+
width: 100%;
|
|
737
|
+
padding: 7px 9px;
|
|
738
|
+
border: 0;
|
|
739
|
+
border-radius: 8px;
|
|
740
|
+
background: none;
|
|
741
|
+
color: var(--fg);
|
|
742
|
+
text-align: left;
|
|
743
|
+
cursor: pointer;
|
|
744
|
+
font: 500 13px var(--mono);
|
|
745
|
+
}
|
|
746
|
+
.slash-item.active,
|
|
747
|
+
.slash-item:hover { background: var(--theirs); }
|
|
748
|
+
.slash-cmd { color: var(--accent); font-weight: 700; }
|
|
749
|
+
.slash-desc { color: var(--muted); font: 400 12px var(--serif); }
|
|
710
750
|
.ctx { display: inline-flex; align-items: center; gap: 8px; }
|
|
711
751
|
.ctx-bar { width: 76px; height: 6px; border-radius: 999px; background: var(--border); overflow: hidden; }
|
|
712
752
|
.ctx-fill { display: block; height: 100%; width: 0; border-radius: 999px; background: currentColor; transition: width 0.35s ease; }
|
|
@@ -724,6 +764,26 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
|
|
|
724
764
|
|
|
725
765
|
.card { border: 1px solid var(--border); background: var(--panel); backdrop-filter: blur(14px); border-radius: 14px; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
|
|
726
766
|
|
|
767
|
+
/* ajustes: pestañas internas ---------------------------------------------- */
|
|
768
|
+
#view-ajustes.active { display: block; overflow-y: auto; }
|
|
769
|
+
.ajustes-wrap { width: 100%; max-width: 780px; margin: 0 auto; padding: 18px; display: flex; flex-direction: column; gap: 12px; min-height: 100%; }
|
|
770
|
+
.ajustes-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
|
771
|
+
.ajustes-tab {
|
|
772
|
+
appearance: none; background: none; border: 0; border-bottom: 2px solid transparent;
|
|
773
|
+
padding: 8px 14px; margin-bottom: -1px; color: var(--muted); cursor: pointer;
|
|
774
|
+
font: 600 13px var(--serif); letter-spacing: 0.01em;
|
|
775
|
+
}
|
|
776
|
+
.ajustes-tab:hover { color: var(--fg); }
|
|
777
|
+
.ajustes-tab.active { color: var(--fg); border-bottom-color: var(--accent); }
|
|
778
|
+
.ajustes-panel { display: none; }
|
|
779
|
+
.ajustes-panel.active { display: flex; }
|
|
780
|
+
.ajustes-panel.panel { flex: none; max-width: none; overflow: visible; padding: 6px 2px 18px; }
|
|
781
|
+
.ajustes-panel .verify-line code { font-size: 11px; }
|
|
782
|
+
#agent-settings-controls { display: flex; flex-direction: column; gap: 7px; }
|
|
783
|
+
#agent-settings-controls label { color: var(--muted); font-size: 12px; }
|
|
784
|
+
#agent-settings-controls select { width: 100%; }
|
|
785
|
+
#agent-controls-slot:empty { display: none; }
|
|
786
|
+
|
|
727
787
|
.providers { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
728
788
|
.providers li {
|
|
729
789
|
display: flex;
|
|
@@ -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}).`;
|
|
@@ -75,8 +75,8 @@ 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
|
/**
|
|
@@ -249,7 +249,9 @@ export function startTelegramChannel(
|
|
|
249
249
|
tools: definitions,
|
|
250
250
|
execute,
|
|
251
251
|
signal: controller.signal,
|
|
252
|
-
maxRounds: ctx.config.maxRounds,
|
|
252
|
+
maxRounds: ctx.effectiveMaxRounds?.() ?? ctx.config.maxRounds,
|
|
253
|
+
maxTokens: ctx.effectiveMaxTokens?.() ?? 0,
|
|
254
|
+
maxDurationMs: ctx.effectiveMaxDurationMs?.() ?? 0,
|
|
253
255
|
onEvent(event) {
|
|
254
256
|
if (event.type === 'text') {
|
|
255
257
|
full = event.full;
|
|
@@ -272,10 +274,14 @@ export function startTelegramChannel(
|
|
|
272
274
|
const durationMs = Date.now() - startedAt;
|
|
273
275
|
let body = result.content?.trim() || full.trim();
|
|
274
276
|
if (!body) {
|
|
275
|
-
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') {
|
|
276
282
|
body = `⚠ El agente alcanzó el máximo de rondas (${result.rounds}) sin respuesta final.${
|
|
277
283
|
toolCalls ? ` Hizo ${toolCalls} herramientas.` : ''
|
|
278
|
-
}
|
|
284
|
+
}`;
|
|
279
285
|
} else if (toolCalls) {
|
|
280
286
|
body = '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.';
|
|
281
287
|
} else {
|
|
@@ -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 () => {
|
|
@@ -343,6 +355,69 @@ test('manages MCP servers from the console', async () => {
|
|
|
343
355
|
}
|
|
344
356
|
});
|
|
345
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
|
+
|
|
346
421
|
test('imports an MCP server from a JSON snippet', async () => {
|
|
347
422
|
const { instance, base } = await startServer();
|
|
348
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)",
|