zyn-ai 1.3.5 → 1.3.7

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/src/core/agent.js CHANGED
@@ -6,7 +6,6 @@ const {
6
6
  MODELS,
7
7
  PROVIDER_TIMEOUT_MAX_ATTEMPTS,
8
8
  PROVIDER_TIMEOUT_RETRY_DELAY_MS,
9
- REQUEST_TIMEOUT_MS,
10
9
  } = require('../config');
11
10
  const { chat, chatSilent } = require('../providers/scraperClient');
12
11
  const {
@@ -26,8 +25,6 @@ const { estimateHistoryChars, saveState } = require('../utils/sessionStorage');
26
25
  const { normalizeText, shortText } = require('../utils/text');
27
26
  const { detectLanguage } = require('../i18n');
28
27
 
29
-
30
-
31
28
  function waitForRetry(ms, signal) {
32
29
  if (!ms || ms <= 0) return Promise.resolve();
33
30
  if (signal?.aborted) return Promise.reject(new Error('aborted'));
@@ -53,7 +50,7 @@ function waitForRetry(ms, signal) {
53
50
  function looksLikeActionRequest(text) {
54
51
  const sample = normalizeText(String(text || '')).toLowerCase();
55
52
  if (!sample) return false;
56
- return /(instala|instalar|install|run|ejecuta|ejecutar|crea|crear|build|compile|compila|fix|arregla|corrige|update|actualiza|edita|edit|borra|elimina|remove|descarga|download|busca|search|prueba|test|verifica|check|configura|setup|mueve|move|importa|import|aplica|apply)/i.test(sample);
53
+ return /(instala|install|run|ejecuta|crea|build|compile|compila|fix|arregla|corrige|update|actualiza|edita|edit|borra|delete|remove|descarga|download|busca|search|prueba|test|verifica|check|configura|setup|mueve|move|import|aplica|apply|deploy|despliega|init|npm|git|docker|qemu)/i.test(sample);
57
54
  }
58
55
 
59
56
  async function requestModel(messages, state, ui, options = {}) {
@@ -65,13 +62,11 @@ async function requestModel(messages, state, ui, options = {}) {
65
62
 
66
63
  for (let attempt = 0; attempt < PROVIDER_TIMEOUT_MAX_ATTEMPTS; attempt += 1) {
67
64
  if (signal?.aborted) {
68
- throw new Error(state.language === 'es' ? 'Agente detenido por el usuario (ESC x2)' : 'Agent stopped by the user (ESC x2)');
65
+ throw new Error(state.language === 'es' ? 'Agente detenido por el usuario' : 'Agent stopped by user');
69
66
  }
70
67
  const stopThinking = ui.startThinkingIndicator(state, attempt === 0 ? label : `${label} (${state.language === 'es' ? 'reintento' : 'retry'})`);
71
68
  let answerStarted = false;
72
69
  let thinkingStarted = false;
73
- let timedOut = false;
74
- let timeout = null;
75
70
  const controller = new AbortController();
76
71
  const onExternalAbort = () => controller.abort();
77
72
 
@@ -79,14 +74,6 @@ async function requestModel(messages, state, ui, options = {}) {
79
74
  if (signal.aborted) controller.abort();
80
75
  else signal.addEventListener('abort', onExternalAbort, { once: true });
81
76
  }
82
- const refreshTimeout = () => {
83
- if (timeout) clearTimeout(timeout);
84
- timeout = setTimeout(() => {
85
- timedOut = true;
86
- controller.abort();
87
- }, REQUEST_TIMEOUT_MS);
88
- };
89
- refreshTimeout();
90
77
 
91
78
  try {
92
79
  const result = await chat({
@@ -94,7 +81,6 @@ async function requestModel(messages, state, ui, options = {}) {
94
81
  modelKey: state?.activeModel || DEFAULT_MODEL_KEY,
95
82
  signal: controller.signal,
96
83
  onChunk: (delta, phase) => {
97
- refreshTimeout();
98
84
  if (phase === 'thinking') {
99
85
  if (!thinkingStarted) {
100
86
  stopThinking();
@@ -116,104 +102,173 @@ async function requestModel(messages, state, ui, options = {}) {
116
102
  if (streamOutput) ui.writeAssistantDelta(state, delta);
117
103
  },
118
104
  });
119
- ui.pushAction(state, 'ok', 'Respuesta del modelo recibida');
105
+ ui.pushAction(state, 'ok', 'Respuesta recibida');
120
106
  return result.answer ?? '';
121
107
  } catch (err) {
122
108
  const externalAbort = Boolean(signal?.aborted);
123
109
  const aborted = controller.signal.aborted || err?.name === 'AbortError';
124
- if (aborted && timedOut && !externalAbort && attempt < PROVIDER_TIMEOUT_MAX_ATTEMPTS - 1) {
125
- const waitMinutes = Math.round(PROVIDER_TIMEOUT_RETRY_DELAY_MS / 60000);
126
- ui.logEvent(
127
- state,
128
- 'warn',
129
- state.language === 'es' ? 'Tiempo agotado del proveedor' : 'Provider timeout',
130
- state.language === 'es'
131
- ? `Esperando ${waitMinutes} minutos antes de reenviar (${attempt + 2}/${PROVIDER_TIMEOUT_MAX_ATTEMPTS})`
132
- : `Waiting ${waitMinutes} minutes before resending (${attempt + 2}/${PROVIDER_TIMEOUT_MAX_ATTEMPTS})`,
133
- );
134
- try {
135
- await waitForRetry(PROVIDER_TIMEOUT_RETRY_DELAY_MS, signal);
136
- } catch {
137
- throw new Error(state.language === 'es' ? 'Agente detenido por el usuario (ESC x2)' : 'Agent stopped by the user (ESC x2)');
138
- }
139
- continue;
140
- }
141
- if (aborted) {
142
- if (externalAbort) {
143
- throw new Error(state.language === 'es' ? 'Agente detenido por el usuario (ESC x2)' : 'Agent stopped by the user (ESC x2)');
144
- }
145
- throw new Error(state.language === 'es' ? 'Tiempo agotado del proveedor' : 'Provider timeout exceeded');
146
- }
147
- if (!externalAbort && attempt < PROVIDER_TIMEOUT_MAX_ATTEMPTS - 1) {
148
- ui.logEvent(state, 'warn', state.language === 'es' ? 'Error transitorio, reenviando contexto y skills' : 'Transient error, resending context and skills');
149
- continue;
150
- }
110
+ if (aborted) throw new Error(state.language === 'es' ? 'Tiempo agotado' : 'Timeout');
111
+ if (!externalAbort && attempt < PROVIDER_TIMEOUT_MAX_ATTEMPTS - 1) continue;
151
112
  throw err;
152
113
  } finally {
153
- clearTimeout(timeout);
154
114
  if (signal) signal.removeEventListener('abort', onExternalAbort);
155
115
  stopThinking();
156
116
  if (thinkingStarted) ui.endThinkingStream(state);
157
117
  if (streamOutput && answerStarted) ui.endAssistantStream(state);
158
118
  }
159
119
  }
160
- throw new Error(state.language === 'es' ? 'No se pudo obtener respuesta del proveedor' : 'Could not get provider response');
120
+ throw new Error('Provider unreachable');
161
121
  }
162
122
 
163
- async function summarizeMessages(state, ui, messages) {
123
+ function normalizeCompactMode(mode) {
124
+ const value = String(mode || '').trim().toLowerCase();
125
+ if (value === 'low' || value === 'medium' || value === 'high') return value;
126
+ return 'medium';
127
+ }
128
+
129
+ function compactTextLossless(text) {
130
+ return String(text || '')
131
+ .split('\r\n').join('\n')
132
+ .split('\r').join('\n')
133
+ .replaceAll('\t', ' ')
134
+ .replace(/[ \t]+$/gm, '')
135
+ .replace(/\n{3,}/g, '\n\n')
136
+ .trim();
137
+ }
138
+
139
+ function compactMessageContent(message, mode) {
140
+ const content = compactTextLossless(message?.content ?? '');
141
+ if (mode === 'low') return content;
142
+ const lines = content.split('\n');
143
+ const filtered = lines.filter((line, idx, arr) => !(line.trim() === '' && arr[idx - 1]?.trim() === ''));
144
+ if (mode === 'high') {
145
+ return filtered.slice(0, Math.max(8, Math.min(filtered.length, 24))).join('\n');
146
+ }
147
+ return filtered.join('\n');
148
+ }
149
+
150
+ function mergeMemorySummary(previous, next) {
151
+ const parts = [compactTextLossless(previous), compactTextLossless(next)].filter(Boolean);
152
+ if (parts.length === 0) return '';
153
+ if (parts.length === 1) return parts[0];
154
+ return parts.join('\n\n');
155
+ }
156
+
157
+ async function summarizeMessages(state, ui, messages, mode = 'medium') {
158
+ const compactMode = normalizeCompactMode(mode);
164
159
  const transcript = messages
165
- .map(message => `${message.role.toUpperCase()}:\n${message.content}`)
160
+ .map(message => `${message.role.toUpperCase()}:\n${compactMessageContent(message, compactMode)}`)
166
161
  .join('\n\n');
167
162
 
163
+ if (compactMode === 'low') {
164
+ return compactTextLossless([
165
+ state.memorySummary ? `Memoria previa:\n${state.memorySummary}` : '',
166
+ transcript,
167
+ ].filter(Boolean).join('\n\n'));
168
+ }
169
+
168
170
  const prompt = [
169
171
  {
170
172
  role: 'system',
171
173
  content: [
172
- state.language === 'es' ? 'Compacta la conversacion para memoria persistente del agente.' : 'Compact the conversation for the agent persistent memory.',
173
- state.language === 'es' ? 'Escribe en español y conserva los idiomas/preferencias indicados por el usuario.' : 'Write in English and preserve any languages/preferences requested by the user.',
174
174
  state.language === 'es'
175
- ? 'Resume sin perder detalles críticos: objetivo del proyecto, progreso exacto, decisiones, archivos/rutas tocadas, comandos ejecutados, resultados, errores, credenciales/configuraciones no secretas, restricciones, próximos pasos y preferencias de idioma.'
176
- : 'Summarize without losing critical details: project goal, exact progress, decisions, touched files/paths, executed commands, results, errors, non-secret credentials/configuration, constraints, next steps, and language preferences.',
177
- state.language === 'es' ? 'No inventes; si algo está pendiente márcalo como Pendiente. Mantén nombres propios, rutas, APIs, límites y valores numéricos intactos.' : 'Do not invent; if something is pending mark it as Pending. Keep proper nouns, paths, APIs, limits, and numeric values intact.',
178
- 'Format: compact bullet list with sections Contexto/Progreso/Archivos/Comandos/Pendiente/Preferencias. Max 20 lines.',
175
+ ? 'Eres un sistema de compresion de memoria tecnica profesional.'
176
+ : 'You are a professional technical memory compression system.',
177
+ state.language === 'es'
178
+ ? 'Resume la sesion manteniendo: Objetivos de desarrollo, estructura del proyecto actual, archivos modificados, comandos ejecutados con exito, errores encontrados y estado de los servicios (Docker/QEMU/Backend).'
179
+ : 'Summarize the session while preserving development goals, current project structure, modified files, successful commands, encountered errors, and service state (Docker/QEMU/Backend).',
180
+ state.language === 'es'
181
+ ? 'Se extremadamente conciso. Usa listas de puntos. No pierdas rutas de archivos ni valores de variables de entorno.'
182
+ : 'Be extremely concise. Use bullet points. Do not lose file paths or environment variable values.',
183
+ state.language === 'es'
184
+ ? 'Si algo no se termino, marcalo como [BLOQUEADO] o [PENDIENTE].'
185
+ : 'If something was not finished, mark it as [BLOCKED] or [PENDING].',
186
+ state.language === 'es'
187
+ ? 'Formato: Contexto | Progreso Tecnico | Archivos | Pendientes. Max 20 lineas.'
188
+ : 'Format: Context | Technical Progress | Files | Pending. Max 20 lines.',
179
189
  ].join('\n'),
180
190
  },
181
191
  {
182
192
  role: 'user',
183
193
  content: [
184
- state.memorySummary ? `Memoria previa:\n${state.memorySummary}\n` : '',
185
- 'Conversacion a compactar:',
194
+ state.memorySummary ? `${state.language === 'es' ? 'Memoria previa' : 'Previous memory'}:\n${state.memorySummary}\n` : '',
195
+ state.language === 'es' ? 'Conversacion a resumir:' : 'Conversation to summarize:',
186
196
  transcript,
187
197
  ].join('\n'),
188
198
  },
189
199
  ];
190
200
 
191
- return normalizeText(await requestModel(prompt, state, ui, {
192
- label: state.language === 'es' ? 'Compactando memoria' : 'Compacting memory',
193
- }));
201
+ const label = compactMode === 'high'
202
+ ? (state.language === 'es' ? 'Compactando memoria alta' : 'High memory compaction')
203
+ : (state.language === 'es' ? 'Consolidando memoria técnica' : 'Consolidating technical memory');
204
+
205
+ return normalizeText(await requestModel(prompt, state, ui, { label }));
194
206
  }
195
207
 
196
208
  async function compactHistoryIfNeeded(state, ui) {
197
- if (estimateHistoryChars(state.history) <= MAX_HISTORY_CHARS) {
198
- return;
209
+ if (estimateHistoryChars(state.history) <= MAX_HISTORY_CHARS) return;
210
+ if (state.history.length <= KEEP_RECENT_MESSAGES) return;
211
+
212
+ const splitIndex = Math.max(2, state.history.length - KEEP_RECENT_MESSAGES);
213
+ const oldMessages = state.history.slice(0, splitIndex);
214
+ const recentMessages = state.history.slice(splitIndex);
215
+ const summary = await summarizeMessages(state, ui, oldMessages, 'medium');
216
+
217
+ state.memorySummary = mergeMemorySummary(state.memorySummary, summary);
218
+ state.history = recentMessages;
219
+ ui?.logEvent?.(state, 'info', state.language === 'es' ? 'Memoria compactada' : 'Memory compacted', shortText(summary, 120));
220
+ await appendTranscriptEntry(state.sessionId, {
221
+ type: 'system',
222
+ content: `${state.language === 'es' ? 'Memoria técnica actualizada' : 'Technical memory updated'}:
223
+ ${summary}`,
224
+ });
225
+ }
226
+
227
+ async function compactMemory(state, ui, mode = 'medium', options = {}) {
228
+ const compactMode = normalizeCompactMode(mode);
229
+ const force = Boolean(options.force);
230
+ if (compactMode === 'high' && !force) {
231
+ const confirm = typeof state.tuiConfirm === 'function'
232
+ ? await state.tuiConfirm(
233
+ state.language === 'es' ? 'Confirmación de compactación alta' : 'High compaction confirmation',
234
+ t(state.language, 'compactWarningHigh'),
235
+ )
236
+ : true;
237
+ if (!confirm || String(confirm).toLowerCase() === 'n') return { changed: false, mode: compactMode, warned: true };
199
238
  }
200
239
 
201
- if (state.history.length <= KEEP_RECENT_MESSAGES) {
202
- return;
240
+ if (compactMode === 'low') {
241
+ const nextSummary = compactTextLossless([
242
+ state.memorySummary,
243
+ state.history.map(message => `${message.role.toUpperCase()}: ${compactMessageContent(message, 'low')}`).join('\n'),
244
+ ].filter(Boolean).join('\n\n'));
245
+ const changed = nextSummary !== state.memorySummary;
246
+ state.memorySummary = nextSummary;
247
+ if (changed) {
248
+ await saveState(state);
249
+ ui?.logEvent?.(state, 'info', t(state.language, 'compactDone'), t(state.language, 'compactCommand', { mode: compactMode }));
250
+ } else {
251
+ ui?.logEvent?.(state, 'info', t(state.language, 'compactNoChange'), '');
252
+ }
253
+ return { changed, mode: compactMode };
203
254
  }
204
255
 
205
- const splitIndex = Math.max(2, state.history.length - KEEP_RECENT_MESSAGES);
256
+ const splitIndex = Math.max(1, state.history.length - KEEP_RECENT_MESSAGES);
206
257
  const oldMessages = state.history.slice(0, splitIndex);
207
258
  const recentMessages = state.history.slice(splitIndex);
208
- const summary = await summarizeMessages(state, ui, oldMessages);
209
-
210
- state.memorySummary = summary;
259
+ const summary = await summarizeMessages(state, ui, oldMessages, compactMode);
260
+ const nextSummary = mergeMemorySummary(state.memorySummary, summary);
261
+ const changed = nextSummary !== state.memorySummary || recentMessages.length !== state.history.length;
262
+ state.memorySummary = nextSummary;
211
263
  state.history = recentMessages;
212
- ui.logEvent(state, 'info', state.language === 'es' ? 'Memoria compactada' : 'Memory compacted', shortText(summary, 100));
264
+ ui?.logEvent?.(state, 'info', t(state.language, 'compactDone'), t(state.language, 'compactCommand', { mode: compactMode }));
213
265
  await appendTranscriptEntry(state.sessionId, {
214
266
  type: 'system',
215
- content: `Memoria compactada:\n${summary}`,
267
+ content: `${state.language === 'es' ? 'Memoria técnica actualizada' : 'Technical memory updated'}:
268
+ ${summary}`,
216
269
  });
270
+ await saveState(state);
271
+ return { changed, mode: compactMode };
217
272
  }
218
273
 
219
274
  async function persistSessionState(state, ui) {
@@ -225,27 +280,16 @@ async function answerFromToolResult(input, call, result, state, ui) {
225
280
  const messages = [
226
281
  {
227
282
  role: 'system',
228
- content: [
229
- 'You are Zyn.',
230
- state.language === 'es' ? 'Responde en espanol, directo y solo con la respuesta final.' : 'Respond in English, direct and only with the final answer.',
231
- state.language === 'es' ? 'Usa solo los datos del resultado de herramienta dado.' : 'Use only the data from the provided tool result.',
232
- `Directorio actual: ${state.cwd}`,
233
- ].join('\n'),
283
+ content: `Eres Zyn. Responde de forma técnica y directa basada únicamente en el resultado de la herramienta. Directorio: ${state.cwd}`,
234
284
  },
235
285
  {
236
286
  role: 'user',
237
- content: [
238
- 'Solicitud original del usuario:',
239
- input,
240
- '',
241
- `Resultado de la herramienta ${call.tool}:`,
242
- result,
243
- ].join('\n'),
287
+ content: `Solicitud: ${input}\n\nResultado de ${call.tool}:\n${result}`,
244
288
  },
245
289
  ];
246
290
 
247
291
  const output = await requestModel(messages, state, ui, {
248
- label: state.language === 'es' ? 'Resumiendo resultado' : 'Summarizing result',
292
+ label: state.language === 'es' ? 'Procesando resultado' : 'Processing result',
249
293
  });
250
294
  const parsed = parseAgentResponse(output);
251
295
  return parsed.type === 'final' ? normalizeText(parsed.content) : normalizeText(output);
@@ -257,30 +301,15 @@ async function runAgentTurn(input, state, ui, options = {}) {
257
301
  if (state.turnCount === 1 && state.title === 'New session') {
258
302
  state.title = shortText(input, 60) || state.title;
259
303
  }
260
- ui.logEvent(state, 'info', `${state.language === 'es' ? 'Turno' : 'Turn'} ${state.turnCount}`);
261
-
262
- let toolUsedThisTurn = false;
263
- let finalWithoutToolRetries = 0;
264
304
 
265
305
  const directAction = parseDirectAction(input);
266
306
  if (directAction) {
267
307
  await appendTranscriptEntry(state.sessionId, { type: 'user', content: input });
268
- toolUsedThisTurn = true;
269
308
  const result = await executeToolCall(directAction, state, ui);
270
- await appendTranscriptEntry(state.sessionId, {
271
- type: 'tool',
272
- tool: directAction.tool,
273
- args: directAction.args,
274
- result,
275
- });
309
+ await appendTranscriptEntry(state.sessionId, { type: 'tool', tool: directAction.tool, args: directAction.args, result });
276
310
  const finalAnswer = await answerFromToolResult(input, directAction, result, state, ui);
277
- state.history.push({ role: 'user', content: input });
278
- state.history.push({ role: 'assistant', content: finalAnswer });
279
- await appendTranscriptEntry(state.sessionId, {
280
- type: 'assistant',
281
- content: finalAnswer,
282
- });
283
- ui.logEvent(state, 'ok', state.language === 'es' ? 'Respuesta lista' : 'Response ready');
311
+ state.history.push({ role: 'user', content: input }, { role: 'assistant', content: finalAnswer });
312
+ await appendTranscriptEntry(state.sessionId, { type: 'assistant', content: finalAnswer });
284
313
  await persistSessionState(state, ui);
285
314
  return { content: finalAnswer, rendered: false };
286
315
  }
@@ -288,24 +317,19 @@ async function runAgentTurn(input, state, ui, options = {}) {
288
317
  const turnMessages = [{ role: 'user', content: input }];
289
318
  await appendTranscriptEntry(state.sessionId, { type: 'user', content: input });
290
319
 
320
+ let step = 0;
321
+ let toolUsedThisTurn = false;
322
+ let finalWithoutToolRetries = 0;
291
323
  let lastFingerprint = '';
292
324
  let repeatCount = 0;
293
- const toolPathUsage = new Map();
294
- let step = 0;
295
325
  const turnLanguage = detectLanguage(input, state.language);
296
326
 
297
327
  while (true) {
298
- if (signal?.aborted) {
299
- throw new Error(state.language === 'es' ? 'Agente detenido por el usuario' : 'Agent stopped by the user');
300
- }
328
+ if (signal?.aborted) throw new Error('Aborted');
301
329
 
302
- const injected = typeof state.getQueuedMessages === 'function'
303
- ? state.getQueuedMessages()
304
- : [];
330
+ const injected = typeof state.getQueuedMessages === 'function' ? state.getQueuedMessages() : [];
305
331
  for (const msg of injected) {
306
- const note = `MENSAJE_ADICIONAL_DEL_USUARIO:\n${msg}`;
307
- turnMessages.push({ role: 'user', content: note });
308
- ui.logEvent(state, 'info', state.language === 'es' ? 'Mensaje recibido en vivo' : 'Live message received', shortText(msg, 60));
332
+ turnMessages.push({ role: 'user', content: `INPUT_ADICIONAL:\n${msg}` });
309
333
  }
310
334
 
311
335
  const messages = buildConversationMessages(
@@ -314,173 +338,49 @@ async function runAgentTurn(input, state, ui, options = {}) {
314
338
  buildSystemPrompt(state.cwd, state, { input, language: turnLanguage }),
315
339
  );
316
340
 
317
- const primaryPromise = requestModel(messages, state, ui, {
318
- label: step === 0 ? (state.language === 'es' ? 'Pensando' : 'Thinking') : `${state.language === 'es' ? 'Paso' : 'Step'} ${step + 1}`,
341
+ const raw = await requestModel(messages, state, ui, {
342
+ label: step === 0 ? 'Analizando' : `Paso ${step + 1}`,
319
343
  signal,
320
344
  });
321
345
 
322
- let secondaryResults = [];
323
- if (state.concuerdo) {
324
- const activeKey = state.activeModel || DEFAULT_MODEL_KEY;
325
- const otherKeys = Object.keys(MODELS).filter(k => k !== activeKey);
326
- const CONCUERDO_TIMEOUT = 30000;
327
- const withTimeout = (promise) => Promise.race([
328
- promise,
329
- new Promise(r => setTimeout(() => r(null), CONCUERDO_TIMEOUT)),
330
- ]);
331
- const secondaryPromises = otherKeys.map(k =>
332
- withTimeout(chatSilent({ messages, modelKey: k, signal }).catch(() => null))
333
- );
334
- secondaryResults = secondaryPromises.map((p, i) => ({ promise: p, key: otherKeys[i] }));
335
- }
336
-
337
- const raw = await primaryPromise;
338
346
  let parsed = parseAgentResponse(raw);
339
347
 
340
- if (secondaryResults.length > 0) {
341
- const settled = await Promise.allSettled(secondaryResults.map(s => s.promise));
342
- const extras = [];
343
- let toolSuggestions = [];
344
-
345
- for (let i = 0; i < settled.length; i += 1) {
346
- const val = settled[i].status === 'fulfilled' ? settled[i].value : null;
347
- const label = MODELS[secondaryResults[i].key]?.label || secondaryResults[i].key;
348
-
349
- if (!val?.answer) {
350
- ui.logEvent(state, 'info', `${label} — ${state.language === 'es' ? 'sin respuesta' : 'no response'}`);
351
- continue;
352
- }
353
-
354
- const altParsed = parseAgentResponse(val.answer);
355
-
356
- if (altParsed.type === 'tool') {
357
- toolSuggestions.push({ parsed: altParsed, label });
358
- ui.logEvent(state, 'info', `${label} ${state.language === 'es' ? 'sugiere' : 'suggests'} ${altParsed.tool}`);
359
- } else if (altParsed.type === 'final' && altParsed.content?.trim()) {
360
- extras.push({ content: altParsed.content, label });
361
- ui.logEvent(state, 'info', `${label} ${state.language === 'es' ? 'respondió' : 'responded'}`);
362
- }
363
- }
364
-
365
- if (parsed.type === 'final' && toolSuggestions.length >= 2) {
366
- parsed = toolSuggestions[0].parsed;
367
- ui.logEvent(state, 'info', `${toolSuggestions.length} ${state.language === 'es' ? 'modelos concuerdan' : 'models agree'}: ${parsed.tool}`);
368
- } else if (parsed.type === 'final' && extras.length > 0) {
369
- const activeLabel = MODELS[state.activeModel || DEFAULT_MODEL_KEY]?.label || (state.language === 'es' ? 'Primario' : 'Primary');
370
- ui.logEvent(state, 'info', `${state.language === 'es' ? 'Sintetizando' : 'Synthesizing'}: ${[activeLabel, ...extras.map(e => e.label)].join(' + ')}`);
371
-
372
- const synthMessages = [
373
- {
374
- role: 'system',
375
- content: [
376
- state.language === 'es' ? 'Eres Zyn. Varios modelos IA analizaron la misma pregunta del usuario.' : 'You are Zyn. Several AI models analyzed the same user request.',
377
- state.language === 'es' ? 'Tu trabajo: crear UNA SOLA respuesta final unificada.' : 'Your job: create ONE unified final answer.',
378
- 'Rules:',
379
- state.language === 'es' ? '- NO repitas informacion que ya este cubierta por otro modelo' : '- Do not repeat information already covered by another model',
380
- state.language === 'es' ? '- Integra las perspectivas unicas de cada uno naturalmente' : "- Blend each model's unique insights naturally",
381
- state.language === 'es' ? '- Si todos dicen lo mismo, da UNA respuesta limpia sin redundancia' : '- If they all say the same thing, give one clean non-redundant answer',
382
- state.language === 'es' ? '- Se directo y conciso' : '- Be direct and concise',
383
- state.language === 'es' ? '- Responde en español' : '- Respond in English',
384
- state.language === 'es' ? '- NO menciones que estas sintetizando ni que hay multiples modelos' : '- Do not mention that you are synthesizing or that multiple models are involved',
385
- state.language === 'es' ? '- NO uses separadores --- ni secciones por modelo' : '- Do not use --- separators or per-model sections',
386
- state.language === 'es' ? '- Responde como si fueras un solo agente dando la mejor respuesta posible' : '- Respond like a single agent giving the best possible answer',
387
- ].join('\n'),
388
- },
389
- {
390
- role: 'user',
391
- content: [
392
- `Respuesta de ${activeLabel}:\n${parsed.content}`,
393
- '',
394
- ...extras.map(e => `Respuesta de ${e.label}:\n${e.content}`),
395
- '',
396
- 'Crea la respuesta final unificada:',
397
- ].join('\n'),
398
- },
399
- ];
400
-
401
- try {
402
- const synthesis = await requestModel(synthMessages, state, ui, {
403
- label: 'Concuerdo — unificando',
404
- signal,
405
- });
406
- if (synthesis?.trim()) {
407
- parsed = { type: 'final', content: synthesis.trim() };
408
- ui.logEvent(state, 'info', state.language === 'es' ? '🤝 Respuesta unificada lista' : '🤝 Unified response ready');
409
- }
410
- } catch {
411
- }
412
- } else if (parsed.type === 'tool' && toolSuggestions.length > 0) {
413
- const matching = toolSuggestions.filter(t => t.parsed.tool === parsed.tool);
414
- if (matching.length > 0) {
415
- ui.logEvent(state, 'info', `🤝 ${matching.length + 1} modelos concuerdan: ${parsed.tool}`);
416
- }
348
+ if (state.concuerdo && step === 0) {
349
+ const activeKey = state.activeModel || DEFAULT_MODEL_KEY;
350
+ const otherKeys = Object.keys(MODELS).filter(k => k !== activeKey);
351
+ const secondaryResults = await Promise.all(otherKeys.map(k => chatSilent({ messages, modelKey: k, signal }).catch(() => null)));
352
+
353
+ const suggestions = secondaryResults
354
+ .filter(r => r?.answer)
355
+ .map(r => parseAgentResponse(r.answer));
356
+
357
+ const toolSugg = suggestions.filter(s => s.type === 'tool');
358
+ if (parsed.type === 'final' && toolSugg.length >= 2) {
359
+ parsed = toolSugg[0];
417
360
  }
418
361
  }
419
362
 
420
363
  if (parsed.type === 'final') {
421
- const content = parsed.content.trim();
422
364
  if (looksLikeActionRequest(input) && !toolUsedThisTurn && finalWithoutToolRetries < 2) {
423
- finalWithoutToolRetries += 1;
424
- ui.logEvent(state, 'warn', turnLanguage === 'es' ? 'Sin prueba real todavía' : 'No real attempt yet', turnLanguage === 'es' ? 'Primero intenta una herramienta antes de concluir.' : 'Try a real tool before concluding.');
425
- turnMessages.push({ role: 'assistant', content: content || raw.trim() });
426
- turnMessages.push({
427
- role: 'user',
428
- content: [
429
- turnLanguage === 'es'
430
- ? 'Aun no has probado nada. No des una conclusion ni pasos teoricos.'
431
- : 'You have not actually tried anything yet. Do not give a conclusion or theory steps.',
432
- turnLanguage === 'es'
433
- ? 'Primero intenta una herramienta real adecuada para la tarea.'
434
- : 'First try a real tool that fits the task.',
435
- turnLanguage === 'es'
436
- ? 'Si ninguna herramienta aplica, dilo explicitamente con una sola frase corta y honesta.'
437
- : 'If no tool applies, say so explicitly in one short honest sentence.',
438
- ].join(' '),
439
- });
440
- step += 1;
365
+ finalWithoutToolRetries++;
366
+ turnMessages.push({ role: 'assistant', content: parsed.content });
367
+ turnMessages.push({ role: 'user', content: 'No has ejecutado ninguna herramienta técnica para esta solicitud de acción. Por favor, usa las herramientas necesarias para obtener resultados reales antes de concluir.' });
368
+ step++;
441
369
  continue;
442
370
  }
443
- turnMessages.push({ role: 'assistant', content: content || raw.trim() });
371
+ turnMessages.push({ role: 'assistant', content: parsed.content });
444
372
  state.history.push(...turnMessages);
445
- await appendTranscriptEntry(state.sessionId, {
446
- type: 'assistant',
447
- content,
448
- });
449
- ui.logEvent(state, 'ok', state.language === 'es' ? 'Respuesta lista' : 'Response ready');
373
+ await appendTranscriptEntry(state.sessionId, { type: 'assistant', content: parsed.content });
450
374
  await persistSessionState(state, ui);
451
- return { content, rendered: false };
375
+ return { content: parsed.content, rendered: false };
452
376
  }
453
377
 
454
- const targetPath = parsed.args?.path || '';
455
- const contentSample = typeof parsed.args?.content === 'string'
456
- ? parsed.args.content.slice(0, 120)
457
- : '';
458
- const fingerprint = `${parsed.tool}:${targetPath}:${contentSample}`;
459
- if (targetPath) {
460
- const key = `${parsed.tool}:${targetPath}`;
461
- const nextCount = (toolPathUsage.get(key) || 0) + 1;
462
- toolPathUsage.set(key, nextCount);
463
- if (nextCount >= 20 && ['write_file', 'append_file', 'replace_in_file'].includes(parsed.tool)) {
464
- ui.logEvent(state, 'warn', state.language === 'es' ? 'Posible loop detectado' : 'Possible loop detected', `${parsed.tool} → ${targetPath} x${nextCount}`);
465
- turnMessages.push({
466
- role: 'user',
467
- content: state.language === 'es'
468
- ? `ALTO: ya editaste ${targetPath} varias veces en este turno. No repitas ediciones; valida y responde con type=final.`
469
- : `STOP: you already edited ${targetPath} multiple times in this turn. Do not repeat edits; verify and answer with type=final.`,
470
- });
471
- step += 1;
472
- continue;
473
- }
474
- }
378
+ const fingerprint = `${parsed.tool}:${parsed.args?.path || ''}:${shortText(JSON.stringify(parsed.args || {}), 50)}`;
475
379
  if (fingerprint === lastFingerprint) {
476
- repeatCount += 1;
477
- if (repeatCount >= 19) {
478
- ui.logEvent(state, 'warn', 'Loop detectado', `${parsed.tool} repetido ${repeatCount + 1}x`);
479
- turnMessages.push({
480
- role: 'user',
481
- content: 'ATENCION: Estas repitiendo la misma operacion. La operacion anterior ya fue exitosa. Responde con type=final confirmando lo que hiciste.',
482
- });
483
- step += 1;
380
+ repeatCount++;
381
+ if (repeatCount >= 3) {
382
+ turnMessages.push({ role: 'user', content: 'Estas en un bucle con la misma herramienta y argumentos. Cambia de estrategia o finaliza con el estado actual.' });
383
+ step++;
484
384
  continue;
485
385
  }
486
386
  } else {
@@ -488,61 +388,25 @@ async function runAgentTurn(input, state, ui, options = {}) {
488
388
  repeatCount = 0;
489
389
  }
490
390
 
491
- turnMessages.push({
492
- role: 'assistant',
493
- content: JSON.stringify(
494
- {
495
- type: 'tool',
496
- tool: parsed.tool,
497
- args: sanitizeArgsForModel(parsed),
498
- },
499
- null,
500
- 2,
501
- ),
502
- });
391
+ turnMessages.push({ role: 'assistant', content: JSON.stringify({ type: 'tool', tool: parsed.tool, args: sanitizeArgsForModel(parsed) }) });
503
392
 
504
393
  try {
505
394
  toolUsedThisTurn = true;
506
395
  const result = await executeToolCall(parsed, state, ui);
507
- await appendTranscriptEntry(state.sessionId, {
508
- type: 'tool',
509
- tool: parsed.tool,
510
- args: parsed.args,
511
- result,
512
- });
513
- turnMessages.push({
514
- role: 'user',
515
- content: `TOOL_RESULT\n${buildToolResultMessage(parsed, result)}`,
516
- });
396
+ await appendTranscriptEntry(state.sessionId, { type: 'tool', tool: parsed.tool, args: parsed.args, result });
397
+ turnMessages.push({ role: 'user', content: `TOOL_RESULT\n${buildToolResultMessage(parsed, result)}` });
517
398
  } catch (err) {
518
- ui.logEvent(state, 'error', 'Fallo de herramienta', err.message);
519
- await appendTranscriptEntry(state.sessionId, {
520
- type: 'tool_error',
521
- tool: parsed.tool,
522
- args: parsed.args,
523
- error: err.message,
524
- });
525
- turnMessages.push({
526
- role: 'user',
527
- content: buildToolErrorMessage(parsed, err.message),
528
- });
399
+ turnMessages.push({ role: 'user', content: buildToolErrorMessage(parsed, err.message) });
529
400
  }
530
401
 
531
- step += 1;
532
- if (step >= MAX_TOOL_STEPS && MAX_TOOL_STEPS !== Number.POSITIVE_INFINITY) {
533
- const limitMsg = state.language === 'es'
534
- ? `Se alcanzó el límite de ${MAX_TOOL_STEPS} pasos. No se pueden ejecutar más herramientas en este turno. Responde con un resumen de lo que lograste.`
535
- : `Reached the limit of ${MAX_TOOL_STEPS} steps. No more tools can be executed this turn. Reply with a summary of what was accomplished.`;
536
- turnMessages.push({ role: 'assistant', content: limitMsg });
537
- state.history.push(...turnMessages);
538
- await appendTranscriptEntry(state.sessionId, { type: 'assistant', content: limitMsg });
539
- ui.logEvent(state, 'warn', state.language === 'es' ? 'Límite de pasos alcanzado' : 'Step limit reached');
402
+ step++;
403
+ if (step >= MAX_TOOL_STEPS) {
404
+ const limitMsg = 'Límite de pasos alcanzado. Resumiendo estado actual del sistema.';
405
+ state.history.push(...turnMessages, { role: 'assistant', content: limitMsg });
540
406
  await persistSessionState(state, ui);
541
407
  return { content: limitMsg, rendered: false };
542
408
  }
543
409
  }
544
410
  }
545
411
 
546
- module.exports = {
547
- runAgentTurn,
548
- };
412
+ module.exports = { compactMemory, normalizeCompactMode, runAgentTurn };