chainlesschain 0.42.3 → 0.43.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/package.json +1 -1
- package/src/commands/init.js +1 -1
- package/src/lib/agent-coordinator.js +111 -0
- package/src/lib/agent-core.js +167 -2
- package/src/lib/cli-context-engineering.js +48 -15
- package/src/lib/cowork/debate-review-cli.js +12 -2
- package/src/lib/hierarchical-memory.js +186 -68
- package/src/lib/sub-agent-context.js +296 -0
- package/src/lib/sub-agent-registry.js +186 -0
- package/src/lib/ws-session-manager.js +8 -0
- package/src/repl/agent-repl.js +45 -0
|
@@ -15,9 +15,83 @@ export const MEMORY_CONFIG = {
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
// ─── In-memory layers ────────────────────────────────────────────
|
|
18
|
-
// Map<
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
// Internal storage: Map<namespace, Map<id, entry>>
|
|
19
|
+
// Default namespace "global" preserves backward compatibility.
|
|
20
|
+
const _workingNS = new Map();
|
|
21
|
+
const _shortTermNS = new Map();
|
|
22
|
+
const DEFAULT_NS = "global";
|
|
23
|
+
|
|
24
|
+
function _getWorkingNS(namespace) {
|
|
25
|
+
const ns = namespace || DEFAULT_NS;
|
|
26
|
+
if (!_workingNS.has(ns)) _workingNS.set(ns, new Map());
|
|
27
|
+
return _workingNS.get(ns);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _getShortTermNS(namespace) {
|
|
31
|
+
const ns = namespace || DEFAULT_NS;
|
|
32
|
+
if (!_shortTermNS.has(ns)) _shortTermNS.set(ns, new Map());
|
|
33
|
+
return _shortTermNS.get(ns);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Backward-compatible proxy ──────────────────────────────────
|
|
37
|
+
// Existing code (and tests) access _working/_shortTerm as flat Maps:
|
|
38
|
+
// _working.size, _working.get(id), _working.clear(), _working.delete(id)
|
|
39
|
+
// We proxy these to the default namespace while exposing namespaced internals.
|
|
40
|
+
function _createCompatProxy(nsMap, getNS) {
|
|
41
|
+
return {
|
|
42
|
+
// Flat access — routes to default namespace
|
|
43
|
+
get size() {
|
|
44
|
+
const ns = getNS(DEFAULT_NS);
|
|
45
|
+
return ns.size;
|
|
46
|
+
},
|
|
47
|
+
get(key) {
|
|
48
|
+
const ns = getNS(DEFAULT_NS);
|
|
49
|
+
return ns.get(key);
|
|
50
|
+
},
|
|
51
|
+
set(key, value) {
|
|
52
|
+
const ns = getNS(DEFAULT_NS);
|
|
53
|
+
return ns.set(key, value);
|
|
54
|
+
},
|
|
55
|
+
has(key) {
|
|
56
|
+
const ns = getNS(DEFAULT_NS);
|
|
57
|
+
return ns.has(key);
|
|
58
|
+
},
|
|
59
|
+
delete(key) {
|
|
60
|
+
const ns = getNS(DEFAULT_NS);
|
|
61
|
+
return ns.delete(key);
|
|
62
|
+
},
|
|
63
|
+
values() {
|
|
64
|
+
const ns = getNS(DEFAULT_NS);
|
|
65
|
+
return ns.values();
|
|
66
|
+
},
|
|
67
|
+
entries() {
|
|
68
|
+
const ns = getNS(DEFAULT_NS);
|
|
69
|
+
return ns.entries();
|
|
70
|
+
},
|
|
71
|
+
keys() {
|
|
72
|
+
const ns = getNS(DEFAULT_NS);
|
|
73
|
+
return ns.keys();
|
|
74
|
+
},
|
|
75
|
+
forEach(callback) {
|
|
76
|
+
const ns = getNS(DEFAULT_NS);
|
|
77
|
+
return ns.forEach(callback);
|
|
78
|
+
},
|
|
79
|
+
[Symbol.iterator]() {
|
|
80
|
+
const ns = getNS(DEFAULT_NS);
|
|
81
|
+
return ns[Symbol.iterator]();
|
|
82
|
+
},
|
|
83
|
+
// Clear ALL namespaces (for test cleanup)
|
|
84
|
+
clear() {
|
|
85
|
+
nsMap.clear();
|
|
86
|
+
},
|
|
87
|
+
// ─── Namespace-aware internals (used by this module) ──────
|
|
88
|
+
_nsMap: nsMap,
|
|
89
|
+
_getNS: getNS,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const _working = _createCompatProxy(_workingNS, _getWorkingNS);
|
|
94
|
+
export const _shortTerm = _createCompatProxy(_shortTermNS, _getShortTermNS);
|
|
21
95
|
|
|
22
96
|
// ─── Helpers ─────────────────────────────────────────────────────
|
|
23
97
|
function generateId() {
|
|
@@ -91,6 +165,17 @@ export function ensureMemoryTables(db) {
|
|
|
91
165
|
* Store a memory at the appropriate layer based on importance.
|
|
92
166
|
* core >= 0.9, long-term >= 0.6, short-term >= 0.3, working < 0.3
|
|
93
167
|
*/
|
|
168
|
+
/**
|
|
169
|
+
* Store a memory at the appropriate layer based on importance.
|
|
170
|
+
* core >= 0.9, long-term >= 0.6, short-term >= 0.3, working < 0.3
|
|
171
|
+
*
|
|
172
|
+
* @param {object} db - Database instance
|
|
173
|
+
* @param {string} content - Memory content
|
|
174
|
+
* @param {object} [options]
|
|
175
|
+
* @param {number} [options.importance=0.5]
|
|
176
|
+
* @param {string} [options.type="episodic"]
|
|
177
|
+
* @param {string} [options.namespace] - Namespace for in-memory isolation (default: "global")
|
|
178
|
+
*/
|
|
94
179
|
export function storeMemory(db, content, options = {}) {
|
|
95
180
|
if (!content || !content.trim()) {
|
|
96
181
|
throw new Error("Memory content cannot be empty");
|
|
@@ -101,6 +186,7 @@ export function storeMemory(db, content, options = {}) {
|
|
|
101
186
|
Math.min(1, parseFloat(options.importance) || 0.5),
|
|
102
187
|
);
|
|
103
188
|
const type = options.type || "episodic";
|
|
189
|
+
const namespace = options.namespace || DEFAULT_NS;
|
|
104
190
|
const id = generateId();
|
|
105
191
|
const now = nowISO();
|
|
106
192
|
|
|
@@ -119,14 +205,15 @@ export function storeMemory(db, content, options = {}) {
|
|
|
119
205
|
).run(id, content, type, importance, "long-term", now, now);
|
|
120
206
|
} else if (importance >= 0.3) {
|
|
121
207
|
layer = "short-term";
|
|
122
|
-
|
|
208
|
+
const nsMap = _getShortTermNS(namespace);
|
|
209
|
+
if (nsMap.size >= MEMORY_CONFIG.shortTermCapacity) {
|
|
123
210
|
// Evict oldest
|
|
124
|
-
const oldest = [...
|
|
211
|
+
const oldest = [...nsMap.entries()].sort(
|
|
125
212
|
(a, b) => new Date(a[1].lastAccessed) - new Date(b[1].lastAccessed),
|
|
126
213
|
)[0];
|
|
127
|
-
if (oldest)
|
|
214
|
+
if (oldest) nsMap.delete(oldest[0]);
|
|
128
215
|
}
|
|
129
|
-
|
|
216
|
+
nsMap.set(id, {
|
|
130
217
|
id,
|
|
131
218
|
content,
|
|
132
219
|
type,
|
|
@@ -137,13 +224,14 @@ export function storeMemory(db, content, options = {}) {
|
|
|
137
224
|
});
|
|
138
225
|
} else {
|
|
139
226
|
layer = "working";
|
|
140
|
-
|
|
141
|
-
|
|
227
|
+
const nsMap = _getWorkingNS(namespace);
|
|
228
|
+
if (nsMap.size >= MEMORY_CONFIG.workingCapacity) {
|
|
229
|
+
const oldest = [...nsMap.entries()].sort(
|
|
142
230
|
(a, b) => new Date(a[1].lastAccessed) - new Date(b[1].lastAccessed),
|
|
143
231
|
)[0];
|
|
144
|
-
if (oldest)
|
|
232
|
+
if (oldest) nsMap.delete(oldest[0]);
|
|
145
233
|
}
|
|
146
|
-
|
|
234
|
+
nsMap.set(id, {
|
|
147
235
|
id,
|
|
148
236
|
content,
|
|
149
237
|
type,
|
|
@@ -162,16 +250,22 @@ export function storeMemory(db, content, options = {}) {
|
|
|
162
250
|
/**
|
|
163
251
|
* Search all memory layers with Ebbinghaus forgetting curve.
|
|
164
252
|
* Strengthens recalled memories (spacing effect).
|
|
253
|
+
*
|
|
254
|
+
* When options.namespace is set, searches that namespace's in-memory maps
|
|
255
|
+
* plus the shared long-term/core DB layers. Without namespace, searches
|
|
256
|
+
* the default "global" namespace (backward compatible).
|
|
165
257
|
*/
|
|
166
258
|
export function recallMemory(db, query, options = {}) {
|
|
167
259
|
if (!query || !query.trim()) return [];
|
|
168
260
|
|
|
169
261
|
const limit = Math.max(1, parseInt(options.limit) || 20);
|
|
170
262
|
const pattern = query.toLowerCase();
|
|
263
|
+
const namespace = options.namespace || DEFAULT_NS;
|
|
171
264
|
const results = [];
|
|
172
265
|
|
|
173
|
-
// Search working memory
|
|
174
|
-
|
|
266
|
+
// Search working memory (namespace-scoped)
|
|
267
|
+
const workingNS = _getWorkingNS(namespace);
|
|
268
|
+
for (const mem of workingNS.values()) {
|
|
175
269
|
if (mem.content.toLowerCase().includes(pattern)) {
|
|
176
270
|
const retention = calcRetention(mem.lastAccessed);
|
|
177
271
|
if (retention >= MEMORY_CONFIG.recallThreshold) {
|
|
@@ -182,8 +276,9 @@ export function recallMemory(db, query, options = {}) {
|
|
|
182
276
|
}
|
|
183
277
|
}
|
|
184
278
|
|
|
185
|
-
// Search short-term memory
|
|
186
|
-
|
|
279
|
+
// Search short-term memory (namespace-scoped)
|
|
280
|
+
const shortTermNS = _getShortTermNS(namespace);
|
|
281
|
+
for (const mem of shortTermNS.values()) {
|
|
187
282
|
if (mem.content.toLowerCase().includes(pattern)) {
|
|
188
283
|
const retention = calcRetention(mem.lastAccessed);
|
|
189
284
|
if (retention >= MEMORY_CONFIG.recallThreshold) {
|
|
@@ -260,41 +355,46 @@ export function consolidateMemory(db) {
|
|
|
260
355
|
let promoted = 0;
|
|
261
356
|
let forgotten = 0;
|
|
262
357
|
|
|
263
|
-
// Promote working → short-term
|
|
264
|
-
for (const [
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
358
|
+
// Promote working → short-term (across all namespaces)
|
|
359
|
+
for (const [ns, nsMap] of _workingNS) {
|
|
360
|
+
const shortTermNS = _getShortTermNS(ns);
|
|
361
|
+
for (const [id, mem] of nsMap) {
|
|
362
|
+
const retention = calcRetention(mem.lastAccessed);
|
|
363
|
+
if (retention < MEMORY_CONFIG.recallThreshold) {
|
|
364
|
+
nsMap.delete(id);
|
|
365
|
+
forgotten++;
|
|
366
|
+
} else if (mem.accessCount >= 3) {
|
|
367
|
+
nsMap.delete(id);
|
|
368
|
+
shortTermNS.set(id, { ...mem, lastAccessed: nowISO() });
|
|
369
|
+
promoted++;
|
|
370
|
+
}
|
|
273
371
|
}
|
|
274
372
|
}
|
|
275
373
|
|
|
276
|
-
// Promote short-term → long-term
|
|
277
|
-
for (const [
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
374
|
+
// Promote short-term → long-term (across all namespaces)
|
|
375
|
+
for (const [, nsMap] of _shortTermNS) {
|
|
376
|
+
for (const [id, mem] of nsMap) {
|
|
377
|
+
const retention = calcRetention(mem.lastAccessed);
|
|
378
|
+
if (retention < MEMORY_CONFIG.recallThreshold) {
|
|
379
|
+
nsMap.delete(id);
|
|
380
|
+
forgotten++;
|
|
381
|
+
} else if (mem.accessCount >= 5) {
|
|
382
|
+
nsMap.delete(id);
|
|
383
|
+
const now = nowISO();
|
|
384
|
+
db.prepare(
|
|
385
|
+
`INSERT INTO memory_long_term (id, content, type, importance, access_count, layer, created_at, last_accessed) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
386
|
+
).run(
|
|
387
|
+
id,
|
|
388
|
+
mem.content,
|
|
389
|
+
mem.type,
|
|
390
|
+
mem.importance,
|
|
391
|
+
mem.accessCount,
|
|
392
|
+
"long-term",
|
|
393
|
+
mem.createdAt,
|
|
394
|
+
now,
|
|
395
|
+
);
|
|
396
|
+
promoted++;
|
|
397
|
+
}
|
|
298
398
|
}
|
|
299
399
|
}
|
|
300
400
|
|
|
@@ -324,15 +424,19 @@ function _searchByType(db, query, type, options = {}) {
|
|
|
324
424
|
const pattern = query.toLowerCase();
|
|
325
425
|
const results = [];
|
|
326
426
|
|
|
327
|
-
// In-memory layers
|
|
328
|
-
for (const
|
|
329
|
-
|
|
330
|
-
|
|
427
|
+
// In-memory layers (search all namespaces)
|
|
428
|
+
for (const [, nsMap] of _workingNS) {
|
|
429
|
+
for (const mem of nsMap.values()) {
|
|
430
|
+
if (mem.type === type && mem.content.toLowerCase().includes(pattern)) {
|
|
431
|
+
results.push({ ...mem, layer: "working" });
|
|
432
|
+
}
|
|
331
433
|
}
|
|
332
434
|
}
|
|
333
|
-
for (const
|
|
334
|
-
|
|
335
|
-
|
|
435
|
+
for (const [, nsMap] of _shortTermNS) {
|
|
436
|
+
for (const mem of nsMap.values()) {
|
|
437
|
+
if (mem.type === type && mem.content.toLowerCase().includes(pattern)) {
|
|
438
|
+
results.push({ ...mem, layer: "short-term" });
|
|
439
|
+
}
|
|
336
440
|
}
|
|
337
441
|
}
|
|
338
442
|
|
|
@@ -412,19 +516,23 @@ export function pruneMemory(db, options = {}) {
|
|
|
412
516
|
const maxAgeHours = parseFloat(options.maxAge) || 720; // 30 days default
|
|
413
517
|
let pruned = 0;
|
|
414
518
|
|
|
415
|
-
// Prune in-memory layers by retention
|
|
416
|
-
for (const [
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
519
|
+
// Prune in-memory layers by retention (across all namespaces)
|
|
520
|
+
for (const [, nsMap] of _workingNS) {
|
|
521
|
+
for (const [id, mem] of nsMap) {
|
|
522
|
+
const retention = calcRetention(mem.lastAccessed);
|
|
523
|
+
if (retention < MEMORY_CONFIG.recallThreshold) {
|
|
524
|
+
nsMap.delete(id);
|
|
525
|
+
pruned++;
|
|
526
|
+
}
|
|
421
527
|
}
|
|
422
528
|
}
|
|
423
|
-
for (const [
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
529
|
+
for (const [, nsMap] of _shortTermNS) {
|
|
530
|
+
for (const [id, mem] of nsMap) {
|
|
531
|
+
const retention = calcRetention(mem.lastAccessed);
|
|
532
|
+
if (retention < MEMORY_CONFIG.recallThreshold) {
|
|
533
|
+
nsMap.delete(id);
|
|
534
|
+
pruned++;
|
|
535
|
+
}
|
|
428
536
|
}
|
|
429
537
|
}
|
|
430
538
|
|
|
@@ -460,12 +568,22 @@ export function getMemoryStats(db) {
|
|
|
460
568
|
.prepare(`SELECT COUNT(*) as count FROM memory_sharing`)
|
|
461
569
|
.get();
|
|
462
570
|
|
|
571
|
+
// Sum across all namespaces
|
|
572
|
+
let workingTotal = 0;
|
|
573
|
+
for (const [, nsMap] of _workingNS) workingTotal += nsMap.size;
|
|
574
|
+
let shortTermTotal = 0;
|
|
575
|
+
for (const [, nsMap] of _shortTermNS) shortTermTotal += nsMap.size;
|
|
576
|
+
|
|
463
577
|
return {
|
|
464
|
-
working:
|
|
465
|
-
shortTerm:
|
|
578
|
+
working: workingTotal,
|
|
579
|
+
shortTerm: shortTermTotal,
|
|
466
580
|
longTerm: ltCount.count,
|
|
467
581
|
core: coreCount.count,
|
|
468
582
|
shared: shareCount.count,
|
|
469
|
-
|
|
583
|
+
namespaces: {
|
|
584
|
+
working: [..._workingNS.keys()],
|
|
585
|
+
shortTerm: [..._shortTermNS.keys()],
|
|
586
|
+
},
|
|
587
|
+
total: workingTotal + shortTermTotal + ltCount.count + coreCount.count,
|
|
470
588
|
};
|
|
471
589
|
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-Agent Context — isolated execution context for child agents.
|
|
3
|
+
*
|
|
4
|
+
* Provides message isolation, independent context engineering, tool whitelisting,
|
|
5
|
+
* iteration limits, and result summarization. Sub-agents run in their own
|
|
6
|
+
* context and only return a summary to the parent agent.
|
|
7
|
+
*
|
|
8
|
+
* @module sub-agent-context
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import crypto from "crypto";
|
|
12
|
+
import { CLIContextEngineering } from "./cli-context-engineering.js";
|
|
13
|
+
import { agentLoop, buildSystemPrompt, AGENT_TOOLS } from "./agent-core.js";
|
|
14
|
+
|
|
15
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const DEFAULT_MAX_ITERATIONS = 8;
|
|
18
|
+
const SUMMARY_DIRECT_THRESHOLD = 500; // chars — below this, use result as-is
|
|
19
|
+
const SUMMARY_SECTION_PATTERN =
|
|
20
|
+
/^##\s*(Summary|Result|Output|Conclusion|Answer)/im;
|
|
21
|
+
const TRUNCATE_LENGTH = 500;
|
|
22
|
+
|
|
23
|
+
// ─── SubAgentContext ────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export class SubAgentContext {
|
|
26
|
+
/**
|
|
27
|
+
* Factory method — creates an isolated sub-agent context.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} options
|
|
30
|
+
* @param {string} options.role - Sub-agent role (e.g. "code-review", "summarizer")
|
|
31
|
+
* @param {string} options.task - Task description for the sub-agent
|
|
32
|
+
* @param {string} [options.parentId] - Parent context ID (null for root)
|
|
33
|
+
* @param {string|null} [options.inheritedContext] - Condensed context from parent
|
|
34
|
+
* @param {string[]} [options.allowedTools] - Tool whitelist (null = all tools)
|
|
35
|
+
* @param {number} [options.maxIterations] - Iteration limit
|
|
36
|
+
* @param {number} [options.tokenBudget] - Optional token budget
|
|
37
|
+
* @param {object} [options.db] - Database instance
|
|
38
|
+
* @param {object} [options.permanentMemory] - Permanent memory instance
|
|
39
|
+
* @param {object} [options.llmOptions] - LLM provider/model/key options
|
|
40
|
+
* @param {string} [options.cwd] - Working directory
|
|
41
|
+
* @returns {SubAgentContext}
|
|
42
|
+
*/
|
|
43
|
+
static create(options = {}) {
|
|
44
|
+
return new SubAgentContext(options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
constructor(options = {}) {
|
|
48
|
+
this.id = `sub-${crypto.randomUUID().slice(0, 12)}`;
|
|
49
|
+
this.parentId = options.parentId || null;
|
|
50
|
+
this.role = options.role || "general";
|
|
51
|
+
this.task = options.task || "";
|
|
52
|
+
this.maxIterations = options.maxIterations || DEFAULT_MAX_ITERATIONS;
|
|
53
|
+
this.tokenBudget = options.tokenBudget || null;
|
|
54
|
+
this.inheritedContext = options.inheritedContext || null;
|
|
55
|
+
this.allowedTools = options.allowedTools || null; // null = all
|
|
56
|
+
this.cwd = options.cwd || process.cwd();
|
|
57
|
+
this.status = "active";
|
|
58
|
+
this.result = null;
|
|
59
|
+
this.createdAt = new Date().toISOString();
|
|
60
|
+
this.completedAt = null;
|
|
61
|
+
|
|
62
|
+
// ── Isolated state ──────────────────────────────────────────────
|
|
63
|
+
// Independent message history — never shared with parent
|
|
64
|
+
this.messages = [];
|
|
65
|
+
|
|
66
|
+
// Independent context engine — does not inherit parent's compaction/errors
|
|
67
|
+
this.contextEngine = new CLIContextEngineering({
|
|
68
|
+
db: options.db || null,
|
|
69
|
+
permanentMemory: options.permanentMemory || null,
|
|
70
|
+
scope: {
|
|
71
|
+
taskId: this.id,
|
|
72
|
+
role: this.role,
|
|
73
|
+
parentObjective: this.task,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Track tool usage and token consumption
|
|
78
|
+
this._toolsUsed = [];
|
|
79
|
+
this._tokenCount = 0;
|
|
80
|
+
this._iterationCount = 0;
|
|
81
|
+
|
|
82
|
+
// LLM options for chatWithTools
|
|
83
|
+
this._llmOptions = options.llmOptions || {};
|
|
84
|
+
|
|
85
|
+
// Build isolated system prompt
|
|
86
|
+
const basePrompt = buildSystemPrompt(this.cwd);
|
|
87
|
+
const rolePrompt = `\n\n## Sub-Agent Role: ${this.role}\nYou are a focused sub-agent with the role "${this.role}". Your task is:\n${this.task}\n\nStay focused on this specific task. Be concise and return results directly.`;
|
|
88
|
+
const contextSection = this.inheritedContext
|
|
89
|
+
? `\n\n## Parent Context\n${this.inheritedContext}`
|
|
90
|
+
: "";
|
|
91
|
+
|
|
92
|
+
this.messages.push({
|
|
93
|
+
role: "system",
|
|
94
|
+
content: basePrompt + rolePrompt + contextSection,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Run the sub-agent loop with the given user prompt.
|
|
100
|
+
* Collects events, enforces iteration limit, and returns a structured result.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} userPrompt - The task prompt for this sub-agent
|
|
103
|
+
* @param {object} [loopOptions] - Additional options for agentLoop
|
|
104
|
+
* @returns {Promise<{ summary: string, artifacts: Array, tokenCount: number, toolsUsed: string[], iterationCount: number }>}
|
|
105
|
+
*/
|
|
106
|
+
async run(userPrompt, loopOptions = {}) {
|
|
107
|
+
if (this.status !== "active") {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`SubAgentContext ${this.id} is not active (status: ${this.status})`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Add user message
|
|
114
|
+
this.messages.push({ role: "user", content: userPrompt });
|
|
115
|
+
|
|
116
|
+
const artifacts = [];
|
|
117
|
+
let lastContent = "";
|
|
118
|
+
|
|
119
|
+
// Build filtered tool list
|
|
120
|
+
const tools = this._getFilteredTools();
|
|
121
|
+
|
|
122
|
+
// Merge LLM options
|
|
123
|
+
const options = {
|
|
124
|
+
...this._llmOptions,
|
|
125
|
+
contextEngine: this.contextEngine,
|
|
126
|
+
cwd: this.cwd,
|
|
127
|
+
...loopOptions,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
// Use a separate messages array for the agent loop
|
|
132
|
+
// The agentLoop will append to this.messages directly
|
|
133
|
+
const gen = agentLoop(this.messages, options);
|
|
134
|
+
|
|
135
|
+
for await (const event of gen) {
|
|
136
|
+
this._iterationCount++;
|
|
137
|
+
|
|
138
|
+
if (event.type === "tool-executing") {
|
|
139
|
+
this._toolsUsed.push(event.tool);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (event.type === "tool-result") {
|
|
143
|
+
// Store large tool results as artifacts
|
|
144
|
+
const resultStr = JSON.stringify(event.result);
|
|
145
|
+
// Estimate token count from tool result (~4 chars per token)
|
|
146
|
+
this._tokenCount += Math.ceil(resultStr.length / 4);
|
|
147
|
+
if (resultStr.length > 2000) {
|
|
148
|
+
artifacts.push({
|
|
149
|
+
type: "tool-output",
|
|
150
|
+
tool: event.tool,
|
|
151
|
+
content: resultStr,
|
|
152
|
+
truncated: resultStr.length > 10000,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (event.type === "response-complete") {
|
|
158
|
+
lastContent = event.content || "";
|
|
159
|
+
// Estimate token count from response content (~4 chars per token)
|
|
160
|
+
this._tokenCount += Math.ceil((lastContent.length || 0) / 4);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Enforce token budget
|
|
164
|
+
if (this.tokenBudget && this._tokenCount >= this.tokenBudget) {
|
|
165
|
+
this.forceComplete("token-budget-exceeded");
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Enforce iteration limit
|
|
170
|
+
if (this._iterationCount >= this.maxIterations * 3) {
|
|
171
|
+
// 3 events per iteration (executing + result + potential response)
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
this.status = "failed";
|
|
177
|
+
this.completedAt = new Date().toISOString();
|
|
178
|
+
this.result = {
|
|
179
|
+
summary: `Sub-agent failed: ${err.message}`,
|
|
180
|
+
artifacts: [],
|
|
181
|
+
tokenCount: this._tokenCount,
|
|
182
|
+
toolsUsed: [...new Set(this._toolsUsed)],
|
|
183
|
+
iterationCount: this._iterationCount,
|
|
184
|
+
};
|
|
185
|
+
return this.result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Summarize the result
|
|
189
|
+
const summary = this.summarize(lastContent);
|
|
190
|
+
|
|
191
|
+
this.status = "completed";
|
|
192
|
+
this.completedAt = new Date().toISOString();
|
|
193
|
+
this.result = {
|
|
194
|
+
summary,
|
|
195
|
+
artifacts,
|
|
196
|
+
tokenCount: this._tokenCount,
|
|
197
|
+
toolsUsed: [...new Set(this._toolsUsed)],
|
|
198
|
+
iterationCount: this._iterationCount,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return this.result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Three-level summarization strategy.
|
|
206
|
+
*
|
|
207
|
+
* 1. Direct use — result ≤ 500 chars → return as-is
|
|
208
|
+
* 2. Section extraction — if result contains ## Summary/Result → extract that section
|
|
209
|
+
* 3. Truncate + artifact — take first 500 chars, store full output as artifact
|
|
210
|
+
*
|
|
211
|
+
* @param {string} content - Raw result content
|
|
212
|
+
* @returns {string} Summarized content
|
|
213
|
+
*/
|
|
214
|
+
summarize(content) {
|
|
215
|
+
if (!content || content.length === 0) {
|
|
216
|
+
return "(No output from sub-agent)";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Strategy 1: Direct use for short content
|
|
220
|
+
if (content.length <= SUMMARY_DIRECT_THRESHOLD) {
|
|
221
|
+
return content;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Strategy 2: Extract structured section
|
|
225
|
+
const match = content.match(SUMMARY_SECTION_PATTERN);
|
|
226
|
+
if (match) {
|
|
227
|
+
const sectionStart = match.index;
|
|
228
|
+
// Find end of section (next ## heading or end of string)
|
|
229
|
+
const rest = content.slice(sectionStart + match[0].length);
|
|
230
|
+
const nextHeading = rest.search(/^##\s/m);
|
|
231
|
+
const section =
|
|
232
|
+
nextHeading >= 0 ? rest.slice(0, nextHeading).trim() : rest.trim();
|
|
233
|
+
if (section.length > 0 && section.length <= 1000) {
|
|
234
|
+
return section;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Strategy 3: Truncate + note
|
|
239
|
+
return (
|
|
240
|
+
content.substring(0, TRUNCATE_LENGTH) +
|
|
241
|
+
`\n...[truncated, full output: ${content.length} chars]`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get filtered tools based on allowedTools whitelist.
|
|
247
|
+
* @returns {Array} Filtered AGENT_TOOLS
|
|
248
|
+
*/
|
|
249
|
+
_getFilteredTools() {
|
|
250
|
+
if (!this.allowedTools || this.allowedTools.length === 0) {
|
|
251
|
+
return AGENT_TOOLS;
|
|
252
|
+
}
|
|
253
|
+
return AGENT_TOOLS.filter((t) =>
|
|
254
|
+
this.allowedTools.includes(t.function.name),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Force-complete this sub-agent (e.g. on timeout or parent cancellation).
|
|
260
|
+
* @param {string} [reason] - Reason for force-completion
|
|
261
|
+
*/
|
|
262
|
+
forceComplete(reason = "forced") {
|
|
263
|
+
if (this.status === "active") {
|
|
264
|
+
this.status = "completed";
|
|
265
|
+
this.completedAt = new Date().toISOString();
|
|
266
|
+
if (!this.result) {
|
|
267
|
+
this.result = {
|
|
268
|
+
summary: `(Sub-agent force-completed: ${reason})`,
|
|
269
|
+
artifacts: [],
|
|
270
|
+
tokenCount: this._tokenCount,
|
|
271
|
+
toolsUsed: [...new Set(this._toolsUsed)],
|
|
272
|
+
iterationCount: this._iterationCount,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Get a serializable snapshot of this context (for debugging/logging).
|
|
280
|
+
*/
|
|
281
|
+
toJSON() {
|
|
282
|
+
return {
|
|
283
|
+
id: this.id,
|
|
284
|
+
parentId: this.parentId,
|
|
285
|
+
role: this.role,
|
|
286
|
+
task: this.task,
|
|
287
|
+
status: this.status,
|
|
288
|
+
messageCount: this.messages.length,
|
|
289
|
+
toolsUsed: [...new Set(this._toolsUsed)],
|
|
290
|
+
tokenCount: this._tokenCount,
|
|
291
|
+
iterationCount: this._iterationCount,
|
|
292
|
+
createdAt: this.createdAt,
|
|
293
|
+
completedAt: this.completedAt,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|