notsleep 1.0.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/.env.example +117 -0
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/dashboard/index.html +893 -0
- package/dist/agent.d.ts +162 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +1064 -0
- package/dist/agent.js.map +1 -0
- package/dist/api.d.ts +10 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +39 -0
- package/dist/api.js.map +1 -0
- package/dist/checkpoint.d.ts +52 -0
- package/dist/checkpoint.d.ts.map +1 -0
- package/dist/checkpoint.js +234 -0
- package/dist/checkpoint.js.map +1 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +265 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +15 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +237 -0
- package/dist/context.js.map +1 -0
- package/dist/cost.d.ts +26 -0
- package/dist/cost.d.ts.map +1 -0
- package/dist/cost.js +161 -0
- package/dist/cost.js.map +1 -0
- package/dist/dashboard.d.ts +96 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +292 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/fileconfig.d.ts +129 -0
- package/dist/fileconfig.d.ts.map +1 -0
- package/dist/fileconfig.js +391 -0
- package/dist/fileconfig.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +882 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +9 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +221 -0
- package/dist/init.js.map +1 -0
- package/dist/logger.d.ts +33 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +150 -0
- package/dist/logger.js.map +1 -0
- package/dist/modelsdev.d.ts +63 -0
- package/dist/modelsdev.d.ts.map +1 -0
- package/dist/modelsdev.js +265 -0
- package/dist/modelsdev.js.map +1 -0
- package/dist/parallel.d.ts +54 -0
- package/dist/parallel.d.ts.map +1 -0
- package/dist/parallel.js +139 -0
- package/dist/parallel.js.map +1 -0
- package/dist/pool.d.ts +17 -0
- package/dist/pool.d.ts.map +1 -0
- package/dist/pool.js +53 -0
- package/dist/pool.js.map +1 -0
- package/dist/provider.d.ts +60 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +396 -0
- package/dist/provider.js.map +1 -0
- package/dist/registry.d.ts +60 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +153 -0
- package/dist/registry.js.map +1 -0
- package/dist/safety.d.ts +21 -0
- package/dist/safety.d.ts.map +1 -0
- package/dist/safety.js +118 -0
- package/dist/safety.js.map +1 -0
- package/dist/schema.d.ts +9 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +166 -0
- package/dist/schema.js.map +1 -0
- package/dist/test-dashboard.d.ts +5 -0
- package/dist/test-dashboard.d.ts.map +1 -0
- package/dist/test-dashboard.js +202 -0
- package/dist/test-dashboard.js.map +1 -0
- package/dist/tools.d.ts +27 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +1212 -0
- package/dist/tools.js.map +1 -0
- package/dist/types.d.ts +180 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/dist/worktree.d.ts +50 -0
- package/dist/worktree.d.ts.map +1 -0
- package/dist/worktree.js +186 -0
- package/dist/worktree.js.map +1 -0
- package/package.json +67 -0
package/dist/agent.js
ADDED
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ============================================================
|
|
3
|
+
// NotSleep - Long-Horizon Agent Loop
|
|
4
|
+
// ============================================================
|
|
5
|
+
//
|
|
6
|
+
// This is the core engine. It:
|
|
7
|
+
// 1. Runs an iterative loop calling the LLM and executing tools
|
|
8
|
+
// 2. Monitors context window usage and summarizes when needed
|
|
9
|
+
// 3. Saves checkpoints for crash recovery
|
|
10
|
+
// 4. Handles errors gracefully and keeps going
|
|
11
|
+
// 5. In SINGLE mode: stops when task_complete is called
|
|
12
|
+
// 6. In CONTINUOUS mode: after task_complete, picks up the next
|
|
13
|
+
// queued task or asks the LLM to generate new tasks. Only
|
|
14
|
+
// stops on STOP file, Ctrl+C, or max iterations.
|
|
15
|
+
//
|
|
16
|
+
// ============================================================
|
|
17
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
18
|
+
if (k2 === undefined) k2 = k;
|
|
19
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
20
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
21
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
22
|
+
}
|
|
23
|
+
Object.defineProperty(o, k2, desc);
|
|
24
|
+
}) : (function(o, m, k, k2) {
|
|
25
|
+
if (k2 === undefined) k2 = k;
|
|
26
|
+
o[k2] = m[k];
|
|
27
|
+
}));
|
|
28
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
29
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
30
|
+
}) : function(o, v) {
|
|
31
|
+
o["default"] = v;
|
|
32
|
+
});
|
|
33
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
34
|
+
var ownKeys = function(o) {
|
|
35
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
36
|
+
var ar = [];
|
|
37
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
38
|
+
return ar;
|
|
39
|
+
};
|
|
40
|
+
return ownKeys(o);
|
|
41
|
+
};
|
|
42
|
+
return function (mod) {
|
|
43
|
+
if (mod && mod.__esModule) return mod;
|
|
44
|
+
var result = {};
|
|
45
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
46
|
+
__setModuleDefault(result, mod);
|
|
47
|
+
return result;
|
|
48
|
+
};
|
|
49
|
+
})();
|
|
50
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
51
|
+
exports.Agent = void 0;
|
|
52
|
+
exports.sanitizeMessageWindow = sanitizeMessageWindow;
|
|
53
|
+
const fs = __importStar(require("fs"));
|
|
54
|
+
const child_process_1 = require("child_process");
|
|
55
|
+
const types_1 = require("./types");
|
|
56
|
+
const provider_1 = require("./provider");
|
|
57
|
+
const checkpoint_1 = require("./checkpoint");
|
|
58
|
+
const tools_1 = require("./tools");
|
|
59
|
+
const cost_1 = require("./cost");
|
|
60
|
+
const modelsdev_1 = require("./modelsdev");
|
|
61
|
+
const config_1 = require("./config");
|
|
62
|
+
/**
|
|
63
|
+
* Make an arbitrary slice of the message history a VALID conversation:
|
|
64
|
+
* - every `tool` message has a matching preceding `assistant` tool_call
|
|
65
|
+
* - every `assistant` tool_call has all of its `tool` results present
|
|
66
|
+
* - no leading `tool` message (which would be an orphaned result)
|
|
67
|
+
*
|
|
68
|
+
* Runs to a fixed point because removing a dangling assistant can orphan a
|
|
69
|
+
* tool result, and removing an orphaned tool result can leave an assistant
|
|
70
|
+
* with fewer results. Used by the context-truncation fallback.
|
|
71
|
+
*/
|
|
72
|
+
function sanitizeMessageWindow(window) {
|
|
73
|
+
let msgs = [...window];
|
|
74
|
+
for (let pass = 0; pass < 10; pass++) {
|
|
75
|
+
// IDs that actually have a tool result present.
|
|
76
|
+
const resultIds = new Set(msgs.filter((m) => m.role === 'tool' && m.tool_call_id).map((m) => m.tool_call_id));
|
|
77
|
+
// Drop assistant messages whose tool_calls are not all answered.
|
|
78
|
+
const keptAssistants = msgs.filter((m) => {
|
|
79
|
+
if (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0) {
|
|
80
|
+
return m.tool_calls.every((tc) => resultIds.has(tc.id));
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
});
|
|
84
|
+
// Valid tool_call IDs from the assistant messages we kept.
|
|
85
|
+
const callIds = new Set();
|
|
86
|
+
for (const m of keptAssistants) {
|
|
87
|
+
if (m.role === 'assistant' && m.tool_calls) {
|
|
88
|
+
for (const tc of m.tool_calls)
|
|
89
|
+
callIds.add(tc.id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Drop tool results with no matching preceding assistant tool_call.
|
|
93
|
+
let cleaned = keptAssistants.filter((m) => {
|
|
94
|
+
if (m.role === 'tool')
|
|
95
|
+
return m.tool_call_id ? callIds.has(m.tool_call_id) : false;
|
|
96
|
+
return true;
|
|
97
|
+
});
|
|
98
|
+
// Drop any leading tool messages (orphaned results at the window start).
|
|
99
|
+
while (cleaned.length > 0 && cleaned[0].role === 'tool') {
|
|
100
|
+
cleaned = cleaned.slice(1);
|
|
101
|
+
}
|
|
102
|
+
if (cleaned.length === msgs.length)
|
|
103
|
+
return cleaned; // stable
|
|
104
|
+
msgs = cleaned;
|
|
105
|
+
}
|
|
106
|
+
return msgs;
|
|
107
|
+
}
|
|
108
|
+
class Agent {
|
|
109
|
+
config;
|
|
110
|
+
provider;
|
|
111
|
+
logger;
|
|
112
|
+
checkpoint;
|
|
113
|
+
tools;
|
|
114
|
+
messages;
|
|
115
|
+
stats;
|
|
116
|
+
taskDescription;
|
|
117
|
+
progress;
|
|
118
|
+
running;
|
|
119
|
+
taskComplete;
|
|
120
|
+
completionSummary;
|
|
121
|
+
paused;
|
|
122
|
+
/** Shared task queue - tools can push to this, agent reads from it */
|
|
123
|
+
taskQueue;
|
|
124
|
+
currentTaskId;
|
|
125
|
+
/**
|
|
126
|
+
* The value of stats.totalIterations when the CURRENT task started. Used to
|
|
127
|
+
* enforce maxIterationsPerTask: if a task runs longer than the per-task
|
|
128
|
+
* budget without calling task_complete, the agent auto-advances to the next
|
|
129
|
+
* queued task so an open-ended task can't starve the queue forever.
|
|
130
|
+
*/
|
|
131
|
+
currentTaskStartIteration = 0;
|
|
132
|
+
/** Optional dashboard for web UI */
|
|
133
|
+
dashboard = null;
|
|
134
|
+
/**
|
|
135
|
+
* Exact prompt-token count reported by the provider on the last chat call.
|
|
136
|
+
* This is the real API measurement of the context size at that moment —
|
|
137
|
+
* NOT a chars/4 guess. 0 until the first response arrives.
|
|
138
|
+
*/
|
|
139
|
+
lastPromptTokens = 0;
|
|
140
|
+
/**
|
|
141
|
+
* How many messages had been sent when lastPromptTokens was measured.
|
|
142
|
+
* Messages appended after this index (the assistant reply + tool results)
|
|
143
|
+
* haven't been counted by the API yet, so only that small tail is estimated.
|
|
144
|
+
*/
|
|
145
|
+
lastPromptMessageCount = 0;
|
|
146
|
+
/**
|
|
147
|
+
* Real completion-token count from the last chat call (API-reported).
|
|
148
|
+
*/
|
|
149
|
+
lastCompletionTokens = 0;
|
|
150
|
+
/**
|
|
151
|
+
* Number of CONSECUTIVE failed iterations. Reset to 0 after any success.
|
|
152
|
+
* The agent stops only when this hits the limit — meaning it's genuinely
|
|
153
|
+
* stuck — rather than on a lifetime error total, which would kill a healthy
|
|
154
|
+
* long run that has merely accumulated transient errors over hours/days.
|
|
155
|
+
*/
|
|
156
|
+
consecutiveErrors = 0;
|
|
157
|
+
static MAX_CONSECUTIVE_ERRORS = 20;
|
|
158
|
+
constructor(config, logger) {
|
|
159
|
+
this.config = config;
|
|
160
|
+
this.logger = logger;
|
|
161
|
+
this.provider = new provider_1.LLMProvider(config, logger);
|
|
162
|
+
this.checkpoint = new checkpoint_1.CheckpointManager(config.checkpointDir, logger);
|
|
163
|
+
this.taskQueue = [];
|
|
164
|
+
this.tools = (0, tools_1.createBuiltinTools)(logger, config.toolTimeoutMs, this.taskQueue, process.cwd());
|
|
165
|
+
// Register user-defined shell tools from notsleep.json, if any.
|
|
166
|
+
const custom = (0, tools_1.createCustomTools)(config.customTools, logger, config.toolTimeoutMs);
|
|
167
|
+
if (custom.length > 0) {
|
|
168
|
+
this.tools.push(...custom);
|
|
169
|
+
logger.info(`Loaded ${custom.length} custom tool(s) from config: ${custom.map((t) => t.name).join(', ')}`);
|
|
170
|
+
}
|
|
171
|
+
this.messages = [];
|
|
172
|
+
this.stats = {
|
|
173
|
+
startTime: Date.now(),
|
|
174
|
+
totalIterations: 0,
|
|
175
|
+
totalTokensUsed: 0,
|
|
176
|
+
totalToolCalls: 0,
|
|
177
|
+
errors: 0,
|
|
178
|
+
contextResets: 0,
|
|
179
|
+
tasksCompleted: 0,
|
|
180
|
+
tasksFailed: 0,
|
|
181
|
+
totalCostUsd: 0,
|
|
182
|
+
};
|
|
183
|
+
this.taskDescription = '';
|
|
184
|
+
this.progress = [];
|
|
185
|
+
this.running = false;
|
|
186
|
+
this.taskComplete = false;
|
|
187
|
+
this.completionSummary = '';
|
|
188
|
+
this.currentTaskId = null;
|
|
189
|
+
this.paused = false;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Register a custom tool.
|
|
193
|
+
*/
|
|
194
|
+
addTool(tool) {
|
|
195
|
+
this.tools.push(tool);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Attach a dashboard server for web UI.
|
|
199
|
+
*/
|
|
200
|
+
setDashboard(dashboard) {
|
|
201
|
+
this.dashboard = dashboard;
|
|
202
|
+
// Wire up logger to push logs to dashboard
|
|
203
|
+
this.logger.onLog = (entry) => {
|
|
204
|
+
dashboard.pushLog(entry);
|
|
205
|
+
};
|
|
206
|
+
// Handle commands from the dashboard UI
|
|
207
|
+
dashboard.onCommand = (cmd) => {
|
|
208
|
+
this.handleDashboardCommand(cmd);
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
handleDashboardCommand(cmd) {
|
|
212
|
+
switch (cmd.type) {
|
|
213
|
+
case 'pause':
|
|
214
|
+
this.paused = true;
|
|
215
|
+
this.logger.info('Agent PAUSED from dashboard.');
|
|
216
|
+
this.broadcastState();
|
|
217
|
+
break;
|
|
218
|
+
case 'resume':
|
|
219
|
+
this.paused = false;
|
|
220
|
+
this.logger.info('Agent RESUMED from dashboard.');
|
|
221
|
+
this.broadcastState();
|
|
222
|
+
break;
|
|
223
|
+
case 'stop':
|
|
224
|
+
this.logger.warn('Agent STOPPED from dashboard. Saving checkpoint...');
|
|
225
|
+
this.saveCheckpoint();
|
|
226
|
+
this.running = false;
|
|
227
|
+
this.broadcastState();
|
|
228
|
+
break;
|
|
229
|
+
case 'add_task':
|
|
230
|
+
if ('description' in cmd && cmd.description) {
|
|
231
|
+
const id = `task-ui-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`;
|
|
232
|
+
const priority = ('priority' in cmd ? cmd.priority : 'medium');
|
|
233
|
+
this.taskQueue.push({
|
|
234
|
+
id,
|
|
235
|
+
description: cmd.description,
|
|
236
|
+
priority,
|
|
237
|
+
status: 'pending',
|
|
238
|
+
addedAt: new Date().toISOString(),
|
|
239
|
+
});
|
|
240
|
+
this.logger.info(`Task added from dashboard [${priority}]: ${cmd.description}`);
|
|
241
|
+
this.broadcastState();
|
|
242
|
+
}
|
|
243
|
+
break;
|
|
244
|
+
case 'delete_task':
|
|
245
|
+
if ('taskId' in cmd && cmd.taskId) {
|
|
246
|
+
const taskId = cmd.taskId;
|
|
247
|
+
const idx = this.taskQueue.findIndex(t => t.id === taskId);
|
|
248
|
+
if (idx !== -1) {
|
|
249
|
+
const removed = this.taskQueue.splice(idx, 1)[0];
|
|
250
|
+
this.logger.info(`Task deleted from dashboard: ${removed.description.substring(0, 60)}`);
|
|
251
|
+
}
|
|
252
|
+
this.broadcastState();
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
case 'get_state':
|
|
256
|
+
this.broadcastState();
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** Push current state to dashboard */
|
|
261
|
+
broadcastState() {
|
|
262
|
+
if (!this.dashboard)
|
|
263
|
+
return;
|
|
264
|
+
let status = 'running';
|
|
265
|
+
if (!this.running)
|
|
266
|
+
status = 'stopped';
|
|
267
|
+
if (this.paused)
|
|
268
|
+
status = 'paused';
|
|
269
|
+
if (this.taskComplete && !this.config.continuous)
|
|
270
|
+
status = 'completed';
|
|
271
|
+
const currentTask = this.currentTaskId
|
|
272
|
+
? this.taskQueue.find(t => t.id === this.currentTaskId)?.description || this.taskDescription
|
|
273
|
+
: this.taskDescription;
|
|
274
|
+
// Context window usage from the REAL API token count (chars/4 only as a
|
|
275
|
+
// pre-first-response fallback), so the dashboard shows true numbers.
|
|
276
|
+
const contextTokens = this.currentContextTokens().tokens;
|
|
277
|
+
const contextUsage = Math.min(contextTokens / this.config.contextWindowLimit, 1);
|
|
278
|
+
// Show the registry provider name (e.g. "limitrouter") when a
|
|
279
|
+
// "provider/model" selector is in use, else the protocol.
|
|
280
|
+
const providerLabel = this.config.model && this.config.model.includes('/')
|
|
281
|
+
? this.config.model.slice(0, this.config.model.indexOf('/'))
|
|
282
|
+
: this.config.provider;
|
|
283
|
+
this.dashboard.updateState({
|
|
284
|
+
status,
|
|
285
|
+
currentTask,
|
|
286
|
+
taskQueue: [...this.taskQueue],
|
|
287
|
+
stats: { ...this.stats },
|
|
288
|
+
provider: providerLabel,
|
|
289
|
+
model: this.getModelName(),
|
|
290
|
+
smallModel: this.config.smallModel?.model,
|
|
291
|
+
contextLimitSource: (0, config_1.getConfigSources)().contextLimitSource,
|
|
292
|
+
continuous: this.config.continuous,
|
|
293
|
+
paused: this.paused,
|
|
294
|
+
recentLogs: [],
|
|
295
|
+
contextUsage,
|
|
296
|
+
contextLimit: this.config.contextWindowLimit,
|
|
297
|
+
maxIterations: this.config.maxIterations,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Run the agent on a task. This is the main entry point.
|
|
302
|
+
*
|
|
303
|
+
* SINGLE MODE (default):
|
|
304
|
+
* Runs until task_complete, maxIterations, or unrecoverable error.
|
|
305
|
+
*
|
|
306
|
+
* CONTINUOUS MODE (config.continuous = true):
|
|
307
|
+
* After task_complete, picks next task from queue. If queue is empty,
|
|
308
|
+
* asks the LLM to generate follow-up tasks. Only stops when:
|
|
309
|
+
* - STOP file is detected (config.stopFile)
|
|
310
|
+
* - Ctrl+C / SIGINT
|
|
311
|
+
* - maxIterations reached
|
|
312
|
+
* - Agent explicitly decides there is nothing left to do
|
|
313
|
+
*/
|
|
314
|
+
async run(taskDescription, resumeFromCheckpoint) {
|
|
315
|
+
this.taskDescription = taskDescription;
|
|
316
|
+
this.running = true;
|
|
317
|
+
// Handle SIGINT gracefully
|
|
318
|
+
const sigintHandler = () => {
|
|
319
|
+
this.logger.warn('Received SIGINT. Saving checkpoint and shutting down gracefully...');
|
|
320
|
+
this.saveCheckpoint();
|
|
321
|
+
this.running = false;
|
|
322
|
+
};
|
|
323
|
+
process.on('SIGINT', sigintHandler);
|
|
324
|
+
try {
|
|
325
|
+
// Check for checkpoint to resume from
|
|
326
|
+
if (resumeFromCheckpoint) {
|
|
327
|
+
const cp = this.checkpoint.loadLatest();
|
|
328
|
+
if (cp) {
|
|
329
|
+
this.messages = cp.messages;
|
|
330
|
+
this.progress = cp.progress;
|
|
331
|
+
this.taskDescription = cp.taskDescription;
|
|
332
|
+
if (cp.taskQueue)
|
|
333
|
+
this.taskQueue.splice(0, this.taskQueue.length, ...cp.taskQueue);
|
|
334
|
+
if (cp.currentTaskId)
|
|
335
|
+
this.currentTaskId = cp.currentTaskId;
|
|
336
|
+
// Restore cumulative stats so tokens/tool-calls/tasks/errors keep
|
|
337
|
+
// counting across the crash. Preserve the ORIGINAL startTime so
|
|
338
|
+
// elapsed time reflects real wall-clock, not just this process.
|
|
339
|
+
if (cp.stats) {
|
|
340
|
+
this.stats = { ...cp.stats };
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
// Older checkpoints only stored the iteration count.
|
|
344
|
+
this.stats.totalIterations = cp.iteration;
|
|
345
|
+
}
|
|
346
|
+
// Restart the per-task budget from here so a resumed task isn't
|
|
347
|
+
// instantly considered over-budget against the restored iteration count.
|
|
348
|
+
this.currentTaskStartIteration = this.stats.totalIterations;
|
|
349
|
+
this.logger.info(`Resuming from checkpoint at iteration ${cp.iteration}`);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
this.logger.info('No checkpoint found, starting fresh.');
|
|
353
|
+
this.initializeMessages(taskDescription);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
this.initializeMessages(taskDescription);
|
|
358
|
+
}
|
|
359
|
+
this.logger.separator();
|
|
360
|
+
this.logger.info(`Starting agent loop for task: ${taskDescription.substring(0, 100)}...`);
|
|
361
|
+
this.logger.info(`Provider: ${this.config.provider} | Model: ${this.getModelName()}`);
|
|
362
|
+
this.logger.info(`Mode: ${this.config.continuous ? 'CONTINUOUS (runs until you wake up)' : 'SINGLE TASK'}`);
|
|
363
|
+
const unlimited = (0, types_1.isUnlimitedIterations)(this.config.maxIterations);
|
|
364
|
+
this.logger.info(`Max iterations: ${unlimited ? 'UNLIMITED' : this.config.maxIterations} | Context limit: ${this.config.contextWindowLimit.toLocaleString()} tokens`);
|
|
365
|
+
if (this.config.continuous) {
|
|
366
|
+
this.logger.info(`Stop file: ${this.config.stopFile} (create this file to stop the agent)`);
|
|
367
|
+
}
|
|
368
|
+
this.logger.separator();
|
|
369
|
+
// ─── Main Agent Loop ───────────────────────────────
|
|
370
|
+
while (this.running && (unlimited || this.stats.totalIterations < this.config.maxIterations)) {
|
|
371
|
+
// ── Check stop conditions ──
|
|
372
|
+
if (this.shouldStop())
|
|
373
|
+
break;
|
|
374
|
+
// ── Wait while paused ──
|
|
375
|
+
while (this.paused && this.running) {
|
|
376
|
+
await this.sleep(500);
|
|
377
|
+
if (this.shouldStop())
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
if (!this.running)
|
|
381
|
+
break;
|
|
382
|
+
// ── Per-task budget: auto-advance if a task runs too long ──
|
|
383
|
+
// In continuous mode, an open-ended task (e.g. "keep improving") may
|
|
384
|
+
// never call task_complete, which would starve every queued task. If a
|
|
385
|
+
// per-task cap is set and exceeded, force-complete the current task and
|
|
386
|
+
// move on so the queue actually drains.
|
|
387
|
+
if (this.config.continuous &&
|
|
388
|
+
!this.taskComplete &&
|
|
389
|
+
this.config.maxIterationsPerTask > 0 &&
|
|
390
|
+
this.stats.totalIterations - this.currentTaskStartIteration >= this.config.maxIterationsPerTask) {
|
|
391
|
+
const spent = this.stats.totalIterations - this.currentTaskStartIteration;
|
|
392
|
+
this.logger.warn(`Task hit its per-task budget (${spent}/${this.config.maxIterationsPerTask} iterations) ` +
|
|
393
|
+
`without completing. Auto-advancing to the next queued task.`);
|
|
394
|
+
this.taskComplete = true;
|
|
395
|
+
this.completionSummary =
|
|
396
|
+
`[auto-advanced] Reached the per-task iteration budget ` +
|
|
397
|
+
`(${this.config.maxIterationsPerTask}) before an explicit task_complete. ` +
|
|
398
|
+
`Partial progress has been auto-committed if enabled.`;
|
|
399
|
+
}
|
|
400
|
+
// ── If current task is complete, handle transition ──
|
|
401
|
+
if (this.taskComplete) {
|
|
402
|
+
this.stats.tasksCompleted++;
|
|
403
|
+
this.markCurrentTaskComplete(this.completionSummary);
|
|
404
|
+
// Auto-commit the completed task's changes (local only, never pushes).
|
|
405
|
+
if (this.config.autoCommit) {
|
|
406
|
+
await this.autoCommitTask(this.completionSummary);
|
|
407
|
+
}
|
|
408
|
+
this.broadcastState();
|
|
409
|
+
if (!this.config.continuous) {
|
|
410
|
+
// Single mode: we're done
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
// Continuous mode: get next task
|
|
414
|
+
this.logger.separator();
|
|
415
|
+
this.logger.info(`Task completed! Looking for next task...`);
|
|
416
|
+
const nextTask = await this.getNextTask();
|
|
417
|
+
if (!nextTask) {
|
|
418
|
+
this.logger.info('No more tasks to do. Agent is stopping.');
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
// Reset for next task
|
|
422
|
+
this.logger.separator();
|
|
423
|
+
this.logger.info(`Starting next task: ${nextTask.description.substring(0, 100)}`);
|
|
424
|
+
this.taskComplete = false;
|
|
425
|
+
this.completionSummary = '';
|
|
426
|
+
this.currentTaskId = nextTask.id;
|
|
427
|
+
nextTask.status = 'in_progress';
|
|
428
|
+
this.currentTaskStartIteration = this.stats.totalIterations;
|
|
429
|
+
// Fresh context for new task, but carry forward the overall mission
|
|
430
|
+
this.messages = [
|
|
431
|
+
{ role: 'system', content: this.config.systemPrompt },
|
|
432
|
+
{
|
|
433
|
+
role: 'user',
|
|
434
|
+
content: `CONTINUOUS MODE: You have completed previous tasks. Here is your next task:\n\n` +
|
|
435
|
+
`${nextTask.description}\n\n` +
|
|
436
|
+
`Previous progress summary:\n${this.progress.slice(-3).join('\n---\n')}\n\n` +
|
|
437
|
+
`Do NOT stop until this task is fully complete. When done, call "task_complete". ` +
|
|
438
|
+
`If you discover additional work needed, use "add_task" to queue it.`,
|
|
439
|
+
},
|
|
440
|
+
];
|
|
441
|
+
}
|
|
442
|
+
this.stats.totalIterations++;
|
|
443
|
+
this.logger.progress(this.stats.totalIterations, this.config.maxIterations, this.stats.totalTokensUsed);
|
|
444
|
+
this.broadcastState();
|
|
445
|
+
try {
|
|
446
|
+
// 1. Check context window and summarize if needed
|
|
447
|
+
await this.manageContext();
|
|
448
|
+
// 2. Call the LLM
|
|
449
|
+
const response = await this.provider.chat(this.messages, this.tools);
|
|
450
|
+
this.stats.totalTokensUsed += response.usage.total_tokens;
|
|
451
|
+
// Accumulate estimated cost from this call's token usage.
|
|
452
|
+
this.stats.totalCostUsd += (0, cost_1.estimateCost)(this.getModelName(), response.usage.prompt_tokens, response.usage.completion_tokens);
|
|
453
|
+
// Remember the EXACT input size the API measured, plus how many
|
|
454
|
+
// messages it covered, so context tracking uses the real number
|
|
455
|
+
// instead of a chars/4 guess.
|
|
456
|
+
if (response.usage.prompt_tokens > 0) {
|
|
457
|
+
this.lastPromptTokens = response.usage.prompt_tokens;
|
|
458
|
+
this.lastPromptMessageCount = this.messages.length;
|
|
459
|
+
}
|
|
460
|
+
this.lastCompletionTokens = response.usage.completion_tokens || 0;
|
|
461
|
+
// 3. Process the response
|
|
462
|
+
await this.processResponse(response);
|
|
463
|
+
// 4. Enforce the cost budget, if one is set.
|
|
464
|
+
if (this.config.maxCostUsd && this.stats.totalCostUsd >= this.config.maxCostUsd) {
|
|
465
|
+
this.logger.warn(`Cost budget reached: estimated ${(0, cost_1.formatUsd)(this.stats.totalCostUsd)} ` +
|
|
466
|
+
`>= limit ${(0, cost_1.formatUsd)(this.config.maxCostUsd)}. Stopping gracefully.`);
|
|
467
|
+
this.saveCheckpoint();
|
|
468
|
+
this.running = false;
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
// A completed iteration means we recovered from any prior errors.
|
|
472
|
+
this.consecutiveErrors = 0;
|
|
473
|
+
// 5. Save checkpoint every 5 iterations
|
|
474
|
+
if (this.stats.totalIterations % 5 === 0) {
|
|
475
|
+
this.saveCheckpoint();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
this.stats.errors++; // lifetime metric (reporting)
|
|
480
|
+
// ── Context overflow: self-correct instead of counting as "stuck" ──
|
|
481
|
+
// If the provider rejected the request because it exceeded the model's
|
|
482
|
+
// context window, the error carries the REAL maximum. Trust it over
|
|
483
|
+
// our detected limit: shrink the limit and summarize immediately so
|
|
484
|
+
// the next attempt fits. This is the reactive safety net beneath the
|
|
485
|
+
// models.dev catalog and /models endpoint sync.
|
|
486
|
+
const overflowMax = (0, modelsdev_1.parseContextOverflow)(error.message);
|
|
487
|
+
if (overflowMax !== undefined) {
|
|
488
|
+
const newLimit = Math.floor(overflowMax * 0.9);
|
|
489
|
+
if (newLimit > 0 && newLimit < this.config.contextWindowLimit) {
|
|
490
|
+
this.logger.warn(`Provider reported a context overflow (model max ${overflowMax.toLocaleString()} tokens). ` +
|
|
491
|
+
`Lowering context limit ${this.config.contextWindowLimit.toLocaleString()} → ` +
|
|
492
|
+
`${newLimit.toLocaleString()} and summarizing.`);
|
|
493
|
+
this.config.contextWindowLimit = newLimit;
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
this.logger.warn(`Context overflow (model max ${overflowMax.toLocaleString()} tokens). Forcing summarization.`);
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
await this.manageContext(true); // force, ignoring the threshold
|
|
500
|
+
}
|
|
501
|
+
catch (e) {
|
|
502
|
+
this.logger.error(`Forced summarization after overflow failed: ${e.message}`);
|
|
503
|
+
}
|
|
504
|
+
// Handled and recoverable — don't let it trip the stuck detector.
|
|
505
|
+
this.consecutiveErrors = 0;
|
|
506
|
+
this.broadcastState();
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
this.consecutiveErrors++; // stuck-detection (stop decision)
|
|
510
|
+
this.logger.error(`Iteration ${this.stats.totalIterations} failed ` +
|
|
511
|
+
`(${this.consecutiveErrors} in a row): ${error.message}`);
|
|
512
|
+
// Add error to conversation so the agent can learn from it, unless
|
|
513
|
+
// we've failed too many times in a row — then we're genuinely stuck.
|
|
514
|
+
if (this.consecutiveErrors < Agent.MAX_CONSECUTIVE_ERRORS) {
|
|
515
|
+
this.messages.push({
|
|
516
|
+
role: 'user',
|
|
517
|
+
content: `[SYSTEM] An error occurred: ${error.message}. Please continue with the task. If this is a persistent issue, try a different approach.`,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
this.logger.error(`${Agent.MAX_CONSECUTIVE_ERRORS} consecutive errors — the agent appears stuck. ` +
|
|
522
|
+
`Stopping. (${this.stats.errors} total errors this run.)`);
|
|
523
|
+
this.running = false;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// ─── Final Checkpoint ──────────────────────────────
|
|
528
|
+
this.saveCheckpoint();
|
|
529
|
+
// ─── Return Results ────────────────────────────────
|
|
530
|
+
const elapsed = ((Date.now() - this.stats.startTime) / 1000).toFixed(0);
|
|
531
|
+
this.logger.separator();
|
|
532
|
+
this.logger.info(`Agent finished after ${this.stats.totalIterations} iterations in ${elapsed}s`);
|
|
533
|
+
this.logger.info(`Total tokens: ${this.stats.totalTokensUsed.toLocaleString()} | Est. cost: ${(0, cost_1.formatUsd)(this.stats.totalCostUsd)}`);
|
|
534
|
+
this.logger.info(`Tool calls: ${this.stats.totalToolCalls} | Errors: ${this.stats.errors} | Context resets: ${this.stats.contextResets}`);
|
|
535
|
+
this.logger.info(`Tasks completed: ${this.stats.tasksCompleted} | Tasks failed: ${this.stats.tasksFailed}`);
|
|
536
|
+
this.logger.info(`Log file: ${this.logger.getLogFile()}`);
|
|
537
|
+
// Show task queue summary
|
|
538
|
+
if (this.taskQueue.length > 0) {
|
|
539
|
+
this.logger.separator();
|
|
540
|
+
this.logger.info('Task Queue Final Status:');
|
|
541
|
+
for (const t of this.taskQueue) {
|
|
542
|
+
this.logger.info(` [${t.status.toUpperCase()}] ${t.description.substring(0, 80)}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
this.logger.separator();
|
|
546
|
+
const isSuccess = this.taskComplete || this.stats.tasksCompleted > 0;
|
|
547
|
+
if (isSuccess) {
|
|
548
|
+
this.logger.agent(`Agent completed successfully! (${this.stats.tasksCompleted} task(s) done)`);
|
|
549
|
+
}
|
|
550
|
+
else if (!(0, types_1.isUnlimitedIterations)(this.config.maxIterations) && this.stats.totalIterations >= this.config.maxIterations) {
|
|
551
|
+
this.logger.warn('Max iterations reached. Task may not be fully complete.');
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
success: isSuccess,
|
|
555
|
+
summary: this.buildFinalSummary(),
|
|
556
|
+
stats: this.stats,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// ─── Private Methods ──────────────────────────────────────
|
|
564
|
+
initializeMessages(taskDescription) {
|
|
565
|
+
const continuousNote = this.config.continuous
|
|
566
|
+
? `\n\nYou are in CONTINUOUS MODE. After completing this task, use "add_task" to queue any follow-up ` +
|
|
567
|
+
`tasks you identify. When the current task is done, call "task_complete" and you will automatically ` +
|
|
568
|
+
`receive the next task. Think proactively - what else could be improved, tested, documented, or built?`
|
|
569
|
+
: '';
|
|
570
|
+
this.messages = [
|
|
571
|
+
{ role: 'system', content: this.config.systemPrompt },
|
|
572
|
+
{ role: 'user', content: `Here is your task. Do NOT stop until it is fully complete:\n\n${taskDescription}${continuousNote}` },
|
|
573
|
+
];
|
|
574
|
+
// In continuous mode, also queue the initial task
|
|
575
|
+
if (this.config.continuous) {
|
|
576
|
+
const id = `task-initial-${Date.now()}`;
|
|
577
|
+
this.taskQueue.push({
|
|
578
|
+
id,
|
|
579
|
+
description: taskDescription,
|
|
580
|
+
priority: 'high',
|
|
581
|
+
status: 'in_progress',
|
|
582
|
+
addedAt: new Date().toISOString(),
|
|
583
|
+
});
|
|
584
|
+
this.currentTaskId = id;
|
|
585
|
+
this.currentTaskStartIteration = this.stats.totalIterations;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
getModelName() {
|
|
589
|
+
if (this.config.provider === 'openai')
|
|
590
|
+
return this.config.openai?.model || 'unknown';
|
|
591
|
+
return this.config.anthropic?.model || 'unknown';
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Auto-commit the changes produced by a completed task. LOCAL ONLY — this
|
|
595
|
+
* never pushes to a remote. Stages all changes and commits with a message
|
|
596
|
+
* derived from the task summary. If there is nothing to commit (e.g. the
|
|
597
|
+
* task made no file changes), it silently does nothing.
|
|
598
|
+
*/
|
|
599
|
+
async autoCommitTask(summary) {
|
|
600
|
+
try {
|
|
601
|
+
// Only commit if inside a git repo.
|
|
602
|
+
const inRepo = await this.runGitQuiet(['rev-parse', '--is-inside-work-tree']);
|
|
603
|
+
if (inRepo.trim() !== 'true') {
|
|
604
|
+
this.logger.debug('auto-commit skipped: not a git repository.');
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
// Anything to commit?
|
|
608
|
+
const status = await this.runGitQuiet(['status', '--porcelain']);
|
|
609
|
+
if (!status.trim()) {
|
|
610
|
+
this.logger.info('auto-commit: no changes to commit for this task.');
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
await this.runGitQuiet(['add', '-A']);
|
|
614
|
+
// Build a concise, single-line commit subject from the summary.
|
|
615
|
+
const firstLine = (summary || 'completed task').split('\n')[0].trim();
|
|
616
|
+
const subject = `notsleep: ${firstLine}`.substring(0, 72);
|
|
617
|
+
const out = await this.runGitQuiet(['commit', '-m', subject]);
|
|
618
|
+
this.logger.info(`auto-commit created: ${subject}`);
|
|
619
|
+
this.logger.debug(out);
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
// Never let a commit failure derail the agent loop.
|
|
623
|
+
this.logger.warn(`auto-commit failed (continuing anyway): ${err.message}`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/** Run a git command via execFile (argv array, no shell). Resolves stdout. */
|
|
627
|
+
runGitQuiet(args) {
|
|
628
|
+
return new Promise((resolve) => {
|
|
629
|
+
(0, child_process_1.execFile)('git', args, { cwd: process.cwd(), maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
630
|
+
if (error && !stdout) {
|
|
631
|
+
resolve(`${stderr || error.message}`);
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
resolve(stdout || '');
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Check if the agent should stop (STOP file, SIGINT already handled).
|
|
641
|
+
*/
|
|
642
|
+
shouldStop() {
|
|
643
|
+
if (!this.running)
|
|
644
|
+
return true;
|
|
645
|
+
// Check for STOP file
|
|
646
|
+
if (this.config.stopFile) {
|
|
647
|
+
try {
|
|
648
|
+
if (fs.existsSync(this.config.stopFile)) {
|
|
649
|
+
this.logger.warn(`STOP file detected at ${this.config.stopFile}. Shutting down gracefully...`);
|
|
650
|
+
// Remove the stop file so next run doesn't immediately stop
|
|
651
|
+
try {
|
|
652
|
+
fs.unlinkSync(this.config.stopFile);
|
|
653
|
+
}
|
|
654
|
+
catch { /* ignore */ }
|
|
655
|
+
// Set running=false so ALL exit paths converge. Without this, a STOP
|
|
656
|
+
// file detected inside the pause loop would be consumed (the file is
|
|
657
|
+
// deleted here) yet the agent would resume, because that loop only
|
|
658
|
+
// breaks itself and the outer check relies on `running`.
|
|
659
|
+
this.running = false;
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
catch { /* ignore fs errors */ }
|
|
664
|
+
}
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Mark the current task as complete in the queue.
|
|
669
|
+
*/
|
|
670
|
+
markCurrentTaskComplete(summary) {
|
|
671
|
+
if (this.currentTaskId) {
|
|
672
|
+
const task = this.taskQueue.find((t) => t.id === this.currentTaskId);
|
|
673
|
+
if (task) {
|
|
674
|
+
task.status = 'completed';
|
|
675
|
+
task.completedAt = new Date().toISOString();
|
|
676
|
+
task.summary = summary.substring(0, 500);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// Store summary in progress
|
|
680
|
+
this.progress.push(`[Task Complete] ${summary}`);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Get the next task to work on. Checks queue first, then asks LLM to generate tasks.
|
|
684
|
+
*/
|
|
685
|
+
async getNextTask() {
|
|
686
|
+
// Sort pending tasks by priority
|
|
687
|
+
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
|
688
|
+
const pendingTasks = this.taskQueue
|
|
689
|
+
.filter((t) => t.status === 'pending')
|
|
690
|
+
.sort((a, b) => (priorityOrder[a.priority] || 1) - (priorityOrder[b.priority] || 1));
|
|
691
|
+
if (pendingTasks.length > 0) {
|
|
692
|
+
this.logger.info(`${pendingTasks.length} task(s) in queue. Picking highest priority.`);
|
|
693
|
+
return pendingTasks[0];
|
|
694
|
+
}
|
|
695
|
+
// No tasks in queue - ask LLM to generate follow-up tasks
|
|
696
|
+
this.logger.info('Queue empty. Asking LLM to identify follow-up tasks...');
|
|
697
|
+
const completedSummaries = this.taskQueue
|
|
698
|
+
.filter((t) => t.status === 'completed')
|
|
699
|
+
.map((t) => `- ${t.description} -> ${t.summary || 'done'}`)
|
|
700
|
+
.join('\n');
|
|
701
|
+
const generateRequest = [
|
|
702
|
+
{
|
|
703
|
+
role: 'system',
|
|
704
|
+
content: 'You are an autonomous AI agent in continuous mode. Your job is to identify useful follow-up tasks ' +
|
|
705
|
+
'based on what has been accomplished. Think about: testing, documentation, optimization, edge cases, ' +
|
|
706
|
+
'error handling, refactoring, security, and anything else that would improve the project.',
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
role: 'user',
|
|
710
|
+
content: `Original goal: ${this.taskDescription}\n\n` +
|
|
711
|
+
`Completed tasks:\n${completedSummaries || '(none)'}\n\n` +
|
|
712
|
+
`Based on what has been done, suggest 1-3 follow-up tasks that would be valuable. ` +
|
|
713
|
+
`If you believe the project is truly complete and there is nothing useful left to do, respond with exactly: NO_MORE_TASKS\n\n` +
|
|
714
|
+
`Otherwise, respond with one task per line in the format:\n` +
|
|
715
|
+
`[high|medium|low] Description of the task`,
|
|
716
|
+
},
|
|
717
|
+
];
|
|
718
|
+
try {
|
|
719
|
+
// Follow-up task generation is an internal call → use the small model.
|
|
720
|
+
const response = await this.provider.chatSmall(generateRequest, []);
|
|
721
|
+
const content = response.content || '';
|
|
722
|
+
if (content.includes('NO_MORE_TASKS')) {
|
|
723
|
+
this.logger.info('LLM determined there are no more tasks to do.');
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
// Parse generated tasks
|
|
727
|
+
const lines = content.split('\n').filter((l) => l.trim().length > 0);
|
|
728
|
+
for (const line of lines) {
|
|
729
|
+
const match = line.match(/\[(high|medium|low)\]\s*(.+)/i);
|
|
730
|
+
if (match) {
|
|
731
|
+
const priority = match[1].toLowerCase();
|
|
732
|
+
const description = match[2].trim();
|
|
733
|
+
const id = `task-gen-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`;
|
|
734
|
+
this.taskQueue.push({
|
|
735
|
+
id,
|
|
736
|
+
description,
|
|
737
|
+
priority,
|
|
738
|
+
status: 'pending',
|
|
739
|
+
addedAt: new Date().toISOString(),
|
|
740
|
+
});
|
|
741
|
+
this.logger.info(`Generated task [${priority}]: ${description.substring(0, 80)}`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
// Return the first newly generated task
|
|
745
|
+
const newPending = this.taskQueue.filter((t) => t.status === 'pending');
|
|
746
|
+
return newPending.length > 0 ? newPending[0] : null;
|
|
747
|
+
}
|
|
748
|
+
catch (error) {
|
|
749
|
+
this.logger.error(`Failed to generate follow-up tasks: ${error.message}`);
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Process an LLM response: display content, execute tool calls.
|
|
755
|
+
*/
|
|
756
|
+
async processResponse(response) {
|
|
757
|
+
// Detect a response that was cut off at max_tokens. When this happens
|
|
758
|
+
// mid-tool-call the arguments JSON is truncated and won't parse, so we
|
|
759
|
+
// flag it explicitly rather than letting it look like a random failure.
|
|
760
|
+
const truncated = response.finish_reason === 'length' || response.finish_reason === 'max_tokens';
|
|
761
|
+
if (truncated) {
|
|
762
|
+
this.logger.warn(`Model output was truncated at max_tokens (finish_reason=${response.finish_reason}). ` +
|
|
763
|
+
`Any tool call in this turn may be incomplete.`);
|
|
764
|
+
}
|
|
765
|
+
// Show agent's text output
|
|
766
|
+
if (response.content) {
|
|
767
|
+
this.logger.agent(response.content);
|
|
768
|
+
}
|
|
769
|
+
// If no tool calls, add the response and prompt the agent to continue
|
|
770
|
+
if (response.tool_calls.length === 0) {
|
|
771
|
+
this.messages.push({
|
|
772
|
+
role: 'assistant',
|
|
773
|
+
content: response.content || '',
|
|
774
|
+
});
|
|
775
|
+
// If the agent just responded with text but didn't call any tool,
|
|
776
|
+
// nudge it to take action or call task_complete
|
|
777
|
+
if (!this.taskComplete) {
|
|
778
|
+
this.messages.push({
|
|
779
|
+
role: 'user',
|
|
780
|
+
content: '[SYSTEM] You responded with text but did not use any tools. If the task is complete, call the "task_complete" tool. Otherwise, use tools to continue making progress.',
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
// Add assistant message with tool calls
|
|
786
|
+
this.messages.push({
|
|
787
|
+
role: 'assistant',
|
|
788
|
+
content: response.content || '',
|
|
789
|
+
tool_calls: response.tool_calls,
|
|
790
|
+
});
|
|
791
|
+
// Execute each tool call
|
|
792
|
+
for (const toolCall of response.tool_calls) {
|
|
793
|
+
this.stats.totalToolCalls++;
|
|
794
|
+
const tool = this.tools.find((t) => t.name === toolCall.function.name);
|
|
795
|
+
if (!tool) {
|
|
796
|
+
this.messages.push({
|
|
797
|
+
role: 'tool',
|
|
798
|
+
tool_call_id: toolCall.id,
|
|
799
|
+
content: `Error: Unknown tool "${toolCall.function.name}". Available tools: ${this.tools.map((t) => t.name).join(', ')}`,
|
|
800
|
+
});
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
let args;
|
|
804
|
+
try {
|
|
805
|
+
args = JSON.parse(toolCall.function.arguments);
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
// A truncated response is the most common cause of unparseable args.
|
|
809
|
+
// Tell the model exactly what happened so it can retry with smaller,
|
|
810
|
+
// well-formed arguments (e.g. write a file in chunks) instead of
|
|
811
|
+
// blindly repeating the same oversized call.
|
|
812
|
+
const hint = truncated
|
|
813
|
+
? 'Your previous response was cut off at the token limit, so these arguments are incomplete. ' +
|
|
814
|
+
'Retry this tool call with shorter arguments — for large files, write them in multiple smaller edits.'
|
|
815
|
+
: 'The arguments were not valid JSON. Retry the tool call with correctly-formatted JSON arguments.';
|
|
816
|
+
this.messages.push({
|
|
817
|
+
role: 'tool',
|
|
818
|
+
tool_call_id: toolCall.id,
|
|
819
|
+
content: `Error: could not parse tool arguments. ${hint}`,
|
|
820
|
+
});
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
this.logger.debug(`Executing tool: ${toolCall.function.name}(${JSON.stringify(args).substring(0, 200)})`);
|
|
824
|
+
try {
|
|
825
|
+
const result = await tool.execute(args);
|
|
826
|
+
// Check for task completion signal
|
|
827
|
+
if (result.startsWith('__TASK_COMPLETE__:')) {
|
|
828
|
+
this.taskComplete = true;
|
|
829
|
+
this.completionSummary = result.replace('__TASK_COMPLETE__:', '');
|
|
830
|
+
this.messages.push({
|
|
831
|
+
role: 'tool',
|
|
832
|
+
tool_call_id: toolCall.id,
|
|
833
|
+
content: 'Task marked as complete.',
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
this.messages.push({
|
|
838
|
+
role: 'tool',
|
|
839
|
+
tool_call_id: toolCall.id,
|
|
840
|
+
content: result,
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
catch (error) {
|
|
845
|
+
this.messages.push({
|
|
846
|
+
role: 'tool',
|
|
847
|
+
tool_call_id: toolCall.id,
|
|
848
|
+
content: `Tool execution error: ${error.message}`,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Approximate token count for a slice of messages.
|
|
855
|
+
*
|
|
856
|
+
* Uses chars/4 for content plus ~4 tokens per-message overhead (role,
|
|
857
|
+
* delimiters, name field, etc. — the real overhead varies by provider
|
|
858
|
+
* but 4 is a reasonable average). This is a FALLBACK; whenever the API
|
|
859
|
+
* has reported a real prompt_tokens count we prefer that.
|
|
860
|
+
*/
|
|
861
|
+
approxTokens(messages) {
|
|
862
|
+
// Per-message overhead: role token, <|im_start|>/<|im_end|> delimiters,
|
|
863
|
+
// optional name field. ~4 tokens on average across providers.
|
|
864
|
+
const PER_MESSAGE_OVERHEAD = 4;
|
|
865
|
+
return Math.ceil(messages.reduce((sum, m) => {
|
|
866
|
+
let chars = m.content?.length || 0;
|
|
867
|
+
if (m.tool_calls)
|
|
868
|
+
chars += JSON.stringify(m.tool_calls).length;
|
|
869
|
+
return sum + chars / 4 + PER_MESSAGE_OVERHEAD;
|
|
870
|
+
}, 0));
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Best available measure of the CURRENT context size in tokens.
|
|
874
|
+
*
|
|
875
|
+
* Prefers the real number the API reported (lastPromptTokens) and only
|
|
876
|
+
* estimates the small tail of messages appended since that measurement.
|
|
877
|
+
* Before the first API response, falls back to a chars/4 estimate.
|
|
878
|
+
*
|
|
879
|
+
* IMPORTANT: The tail typically contains the assistant reply (whose real
|
|
880
|
+
* token count is `lastCompletionTokens`) PLUS tool-result messages. We
|
|
881
|
+
* use the real completion count for the assistant message and only
|
|
882
|
+
* estimate the tool-result messages, avoiding double-counting.
|
|
883
|
+
*/
|
|
884
|
+
currentContextTokens() {
|
|
885
|
+
if (this.lastPromptTokens <= 0) {
|
|
886
|
+
// No API measurement yet — estimate the whole thing.
|
|
887
|
+
return { tokens: this.approxTokens(this.messages), exact: false };
|
|
888
|
+
}
|
|
889
|
+
const tail = this.messages.slice(this.lastPromptMessageCount);
|
|
890
|
+
if (tail.length === 0) {
|
|
891
|
+
// Nothing appended since the API measured — use the exact numbers.
|
|
892
|
+
return {
|
|
893
|
+
tokens: this.lastPromptTokens + this.lastCompletionTokens,
|
|
894
|
+
exact: true,
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
// The tail usually starts with the assistant reply (whose real token
|
|
898
|
+
// count we already know from lastCompletionTokens) followed by tool
|
|
899
|
+
// results. Only estimate the NON-assistant messages to avoid
|
|
900
|
+
// double-counting the completion.
|
|
901
|
+
const toolAndUserTail = tail.filter((m) => m.role !== 'assistant');
|
|
902
|
+
const tailApprox = this.approxTokens(toolAndUserTail);
|
|
903
|
+
return {
|
|
904
|
+
tokens: this.lastPromptTokens + this.lastCompletionTokens + tailApprox,
|
|
905
|
+
exact: false,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Manage context window size. When the conversation gets too long,
|
|
910
|
+
* ask the LLM to summarize progress so we can compress the history.
|
|
911
|
+
*/
|
|
912
|
+
async manageContext(force = false) {
|
|
913
|
+
// Use the REAL API token count (plus a tiny estimated tail), not a guess.
|
|
914
|
+
const ctx = this.currentContextTokens();
|
|
915
|
+
const estimatedTokens = ctx.tokens;
|
|
916
|
+
const threshold = this.config.contextWindowLimit * this.config.summarizeThreshold;
|
|
917
|
+
// `force` is used by the context-overflow recovery path, which must
|
|
918
|
+
// summarize regardless of the (now-stale) threshold estimate.
|
|
919
|
+
if (!force && estimatedTokens < threshold)
|
|
920
|
+
return;
|
|
921
|
+
const prefix = ctx.exact ? 'Context window at' : 'Context window at ~';
|
|
922
|
+
this.logger.warn(`${prefix}${Math.round(estimatedTokens).toLocaleString()} tokens (threshold: ${Math.round(threshold).toLocaleString()}). Summarizing...`);
|
|
923
|
+
this.stats.contextResets++;
|
|
924
|
+
// Ask the LLM to summarize the conversation so far.
|
|
925
|
+
//
|
|
926
|
+
// The summary request itself must fit within the model's context window.
|
|
927
|
+
// We budget roughly half the context limit for the conversation dump
|
|
928
|
+
// (the other half is headroom for the system prompt, instructions, and
|
|
929
|
+
// the model's output). Each message is truncated to keep the total
|
|
930
|
+
// under budget.
|
|
931
|
+
const summaryBudgetTokens = Math.floor(this.config.contextWindowLimit * 0.5);
|
|
932
|
+
const messagesToSummarize = this.messages.slice(0, -1); // exclude last
|
|
933
|
+
const perMsgCharLimit = Math.max(200, Math.floor((summaryBudgetTokens * 4) / Math.max(messagesToSummarize.length, 1)));
|
|
934
|
+
const conversationDump = messagesToSummarize
|
|
935
|
+
.map((m) => {
|
|
936
|
+
const content = (m.content || '').substring(0, perMsgCharLimit);
|
|
937
|
+
const suffix = (m.content || '').length > perMsgCharLimit ? '...[truncated]' : '';
|
|
938
|
+
return `[${m.role}]: ${content}${suffix}`;
|
|
939
|
+
})
|
|
940
|
+
.join('\n\n');
|
|
941
|
+
const summaryRequest = [
|
|
942
|
+
{ role: 'system', content: 'You are a helpful assistant. Summarize the following conversation into a concise progress report.' },
|
|
943
|
+
{
|
|
944
|
+
role: 'user',
|
|
945
|
+
content: `Please provide a detailed summary of everything accomplished so far in this agent session. Include:\n` +
|
|
946
|
+
`1. The original task\n` +
|
|
947
|
+
`2. What has been completed\n` +
|
|
948
|
+
`3. What is currently in progress\n` +
|
|
949
|
+
`4. Any important findings, decisions, or state (file paths, variable values, etc.)\n` +
|
|
950
|
+
`5. What still needs to be done\n\n` +
|
|
951
|
+
`Here is the conversation history:\n\n` +
|
|
952
|
+
conversationDump,
|
|
953
|
+
},
|
|
954
|
+
];
|
|
955
|
+
try {
|
|
956
|
+
// Bound this call hard: the summary is a recovery step, not the task.
|
|
957
|
+
// If the upstream is flaky (repeated 524s / long Retry-After), fail fast
|
|
958
|
+
// to the truncation fallback below instead of blocking the whole run for
|
|
959
|
+
// many minutes retrying a single summarize request.
|
|
960
|
+
// Use the cheaper small model (if configured) for this internal call.
|
|
961
|
+
const summaryResponse = await this.provider.chatSmall(summaryRequest, [], {
|
|
962
|
+
maxRetries: 2,
|
|
963
|
+
maxDelayMs: 15000,
|
|
964
|
+
});
|
|
965
|
+
const summary = summaryResponse.content || 'Unable to generate summary.';
|
|
966
|
+
this.progress.push(summary);
|
|
967
|
+
// Reset messages with the summary injected
|
|
968
|
+
this.messages = [
|
|
969
|
+
{ role: 'system', content: this.config.systemPrompt },
|
|
970
|
+
{
|
|
971
|
+
role: 'user',
|
|
972
|
+
content: `CONTEXT RECOVERY: The conversation was too long and has been summarized. Here is the summary of everything so far:\n\n` +
|
|
973
|
+
`--- PROGRESS SUMMARY ---\n${summary}\n--- END SUMMARY ---\n\n` +
|
|
974
|
+
`Original task: ${this.taskDescription}\n\n` +
|
|
975
|
+
`Continue from where you left off. Do NOT repeat work that was already completed. ` +
|
|
976
|
+
`Use the tools to verify the current state if needed, then continue making progress.`,
|
|
977
|
+
},
|
|
978
|
+
];
|
|
979
|
+
// The old prompt-token count no longer reflects the (now small) context.
|
|
980
|
+
// Reset so the next call re-measures from the compressed history.
|
|
981
|
+
this.lastPromptTokens = 0;
|
|
982
|
+
this.lastPromptMessageCount = 0;
|
|
983
|
+
this.lastCompletionTokens = 0;
|
|
984
|
+
this.logger.info(`Context compressed. New message count: ${this.messages.length}. Previous summaries: ${this.progress.length}`);
|
|
985
|
+
}
|
|
986
|
+
catch (error) {
|
|
987
|
+
this.logger.error(`Failed to summarize context: ${error.message}. Doing hard truncation.`);
|
|
988
|
+
// Fallback: keep system prompt + last N messages. The raw slice can begin
|
|
989
|
+
// with an orphaned tool result or end with a dangling assistant tool_call,
|
|
990
|
+
// both of which are INVALID conversation states that make providers return
|
|
991
|
+
// a 400 — turning a recovery step into an unrecoverable loop. Sanitize the
|
|
992
|
+
// window so tool_calls and tool_results are always properly paired.
|
|
993
|
+
const keepLast = 20;
|
|
994
|
+
const systemMsg = this.messages[0];
|
|
995
|
+
const recentMessages = this.sanitizeMessageWindow(this.messages.slice(-keepLast));
|
|
996
|
+
this.messages = [
|
|
997
|
+
systemMsg,
|
|
998
|
+
{
|
|
999
|
+
role: 'user',
|
|
1000
|
+
content: `[SYSTEM] Context was truncated due to length. Original task: ${this.taskDescription}\n` +
|
|
1001
|
+
`The following are the most recent messages. Continue from where you left off.`,
|
|
1002
|
+
},
|
|
1003
|
+
...recentMessages,
|
|
1004
|
+
];
|
|
1005
|
+
// Stale count reflects the pre-truncation context; reset it.
|
|
1006
|
+
this.lastPromptTokens = 0;
|
|
1007
|
+
this.lastPromptMessageCount = 0;
|
|
1008
|
+
this.lastCompletionTokens = 0;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
/** Delegate to the standalone exported function. */
|
|
1012
|
+
sanitizeMessageWindow(window) {
|
|
1013
|
+
return sanitizeMessageWindow(window);
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Save current state as a checkpoint.
|
|
1017
|
+
*/
|
|
1018
|
+
saveCheckpoint() {
|
|
1019
|
+
const id = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1020
|
+
const checkpoint = {
|
|
1021
|
+
id,
|
|
1022
|
+
timestamp: new Date().toISOString(),
|
|
1023
|
+
iteration: this.stats.totalIterations,
|
|
1024
|
+
messages: this.messages,
|
|
1025
|
+
taskDescription: this.taskDescription,
|
|
1026
|
+
progress: this.progress,
|
|
1027
|
+
provider: this.config.provider,
|
|
1028
|
+
model: this.getModelName(),
|
|
1029
|
+
taskQueue: [...this.taskQueue],
|
|
1030
|
+
currentTaskId: this.currentTaskId || undefined,
|
|
1031
|
+
stats: { ...this.stats },
|
|
1032
|
+
};
|
|
1033
|
+
this.checkpoint.save(checkpoint);
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Build a final summary of everything accomplished.
|
|
1037
|
+
*/
|
|
1038
|
+
buildFinalSummary() {
|
|
1039
|
+
const completed = this.taskQueue.filter((t) => t.status === 'completed');
|
|
1040
|
+
const pending = this.taskQueue.filter((t) => t.status === 'pending');
|
|
1041
|
+
let summary = '';
|
|
1042
|
+
if (completed.length > 0) {
|
|
1043
|
+
summary += `Completed ${completed.length} task(s):\n`;
|
|
1044
|
+
for (const t of completed) {
|
|
1045
|
+
summary += ` - ${t.description}${t.summary ? ': ' + t.summary : ''}\n`;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
if (this.completionSummary && completed.length === 0) {
|
|
1049
|
+
summary += this.completionSummary;
|
|
1050
|
+
}
|
|
1051
|
+
if (pending.length > 0) {
|
|
1052
|
+
summary += `\n${pending.length} task(s) still pending:\n`;
|
|
1053
|
+
for (const t of pending) {
|
|
1054
|
+
summary += ` - [${t.priority}] ${t.description}\n`;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return summary || 'Task did not complete within the iteration limit.';
|
|
1058
|
+
}
|
|
1059
|
+
sleep(ms) {
|
|
1060
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
exports.Agent = Agent;
|
|
1064
|
+
//# sourceMappingURL=agent.js.map
|