File size: 6,082 Bytes
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
// Modo Objetivo (🎯 Goal): mismo patrón planificador/ejecutor que clonagent
// (~/work2026/clonagent) — un PLANIFICADOR descompone el objetivo en una
// lista de tareas con estado (pending/in-progress/done/failed/skipped), y un
// EJECUTOR las va ejecutando una a una hasta que no queda ninguna pendiente,
// marcando el resultado de cada una. Si una tarea falla, las siguientes se
// saltan (cascada) en vez de seguir a ciegas — igual que el fallo→skip de
// dependencias de clonagent.
//
// Aquí el "ejecutor" de cada tarea es el MISMO bucle de tool-calling que ya
// usa el chat normal (agent.handle): cada tarea del plan se manda como si
// fuera un mensaje de usuario, con todo el historial previo como contexto —
// así una tarea puede apoyarse en lo que hizo la anterior, igual que la
// sesión del ejecutor de clonagent tiene a la vista el fichero de tareas
// entero.
import { toolHelp, snapshot } from './tools/index.js';

const MAX_TASK_RETRIES = 2;
const TASK_PREFIX = '[tarea-objetivo] ';

function plannerPrompt(goalText, context) {
  return `ROL: PLANIFICADOR

Objetivo del usuario: "${goalText}"

CONTEXTO AHORA (estado real del IDE):
${context}

Herramientas disponibles para la fase de EJECUCIÓN (aquí solo planificas, no las llames):
${toolHelp()}

Descompón el objetivo en una lista de tareas CONCRETAS, ORDENADAS y ejecutables una tras otra con esas herramientas. Entre 2 y 6 tareas normalmente basta — no fragmentes de más, cada tarea debe ser un paso real con sentido propio.

Responde SOLO con este JSON, nada de texto ni fences alrededor:
{"plan": "una frase resumiendo el enfoque", "tasks": [{"title": "título corto", "description": "qué hay que hacer, concreto y accionable"}]}`;
}

// Extrae el primer objeto JSON balanceado del texto (el modelo a veces mete
// prosa alrededor pese a la instrucción de "SOLO JSON").
function extractJson(text) {
  const start = text.indexOf('{');
  if (start < 0) return null;
  let depth = 0, inStr = false, esc = false;
  for (let i = start; i < text.length; i++) {
    const ch = text[i];
    if (esc) { esc = false; continue; }
    if (ch === '\\') { esc = true; continue; }
    if (ch === '"') inStr = !inStr;
    else if (!inStr) {
      if (ch === '{') depth++;
      else if (ch === '}' && --depth === 0) {
        try { return JSON.parse(text.slice(start, i + 1)); } catch { return null; }
      }
    }
  }
  return null;
}

// Llamada ÚNICA al modelo con el rol de planificador — no toca agent.history
// (el plan resultante se guarda aparte; el objetivo en sí SÍ se añade al
// historial en runGoal, para que las tareas puedan apoyarse en él).
export async function planGoal(agent, goalText) {
  const context = await snapshot().catch(() => '');
  const raw = await agent.provider.chat([{ role: 'user', content: goalText }], plannerPrompt(goalText, context), () => {});
  const parsed = extractJson(raw);
  if (!parsed || !Array.isArray(parsed.tasks) || !parsed.tasks.length) {
    throw new Error('el planificador no devolvió una lista de tareas válida');
  }
  return {
    planText: String(parsed.plan || '').trim(),
    tasks: parsed.tasks.slice(0, 10).map(t => ({
      title: String(t.title || 'tarea').trim(),
      description: String(t.description || t.title || '').trim(),
    })),
  };
}

function clonePlan(plan) { return JSON.parse(JSON.stringify(plan)); }

// Orquesta plan → ejecución. onEvent es el MISMO canal que usa el chat
// normal (conversations.js lo conecta a onConvEvent) — los eventos de las
// tareas (token/tool/tool_result/text/error) se reenvían tal cual, y además
// se emiten 'plan' / 'plan_update' / 'plan_complete' para la tarjeta de plan.
export async function runGoal(conv, goalText, onEvent) {
  conv.agent.history.push({ role: 'user', content: TASK_PREFIX + 'Objetivo: ' + goalText });

  const plan = {
    id: 'g' + Date.now().toString(36), goal: goalText, planText: '', status: 'planning',
    tasks: [], createdAt: Date.now(), updatedAt: Date.now(),
  };
  conv.plan = plan;
  onEvent({ type: 'plan', plan: clonePlan(plan) });

  let parsed;
  try {
    parsed = await planGoal(conv.agent, goalText);
  } catch (e) {
    plan.status = 'failed';
    plan.updatedAt = Date.now();
    onEvent({ type: 'plan_update', plan: clonePlan(plan) });
    onEvent({ type: 'error', text: 'No pude planificar el objetivo: ' + e.message });
    return;
  }

  plan.planText = parsed.planText;
  plan.tasks = parsed.tasks.map((t, i) => ({
    id: 't' + (i + 1), title: t.title, description: t.description,
    status: 'pending', retries: 0, result: '',
  }));
  plan.status = 'running';
  plan.updatedAt = Date.now();
  onEvent({ type: 'plan_update', plan: clonePlan(plan) });

  let cascadeFail = false;
  for (const task of plan.tasks) {
    if (cascadeFail) { task.status = 'skipped'; plan.updatedAt = Date.now(); onEvent({ type: 'plan_update', plan: clonePlan(plan) }); continue; }

    task.status = 'in-progress';
    plan.updatedAt = Date.now();
    onEvent({ type: 'plan_update', plan: clonePlan(plan) });

    const taskPrompt = `${TASK_PREFIX}Tarea ${task.id} del objetivo «${goalText}»: ${task.title}\n${task.description}`;
    let done = false;
    while (!done && task.retries <= MAX_TASK_RETRIES) {
      let sawError = false, errText = '';
      await conv.agent.handle(taskPrompt, ev => {
        if (ev.type === 'error') { sawError = true; errText = ev.text; }
        onEvent(ev);
      });
      if (!sawError) { done = true; }
      else {
        task.retries++;
        task.result = errText;
        if (task.retries > MAX_TASK_RETRIES) break;
      }
    }
    task.status = done ? 'done' : 'failed';
    plan.updatedAt = Date.now();
    onEvent({ type: 'plan_update', plan: clonePlan(plan) });
    if (!done) cascadeFail = true; // el resto se saltan, igual que la cascada de dependencias fallidas en clonagent
  }

  plan.status = plan.tasks.some(t => t.status === 'failed') ? 'failed' : 'done';
  plan.updatedAt = Date.now();
  onEvent({ type: 'plan_complete', plan: clonePlan(plan) });
}

export { TASK_PREFIX };