chainlesschain 0.42.2 → 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.
@@ -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
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Sub-Agent Registry — lifecycle tracking for sub-agents.
3
+ *
4
+ * Tracks active sub-agents, maintains completion history (ring buffer),
5
+ * and provides statistics. Singleton pattern for process-wide access.
6
+ *
7
+ * @module sub-agent-registry
8
+ */
9
+
10
+ // ─── Ring Buffer ────────────────────────────────────────────────────────────
11
+
12
+ class RingBuffer {
13
+ constructor(capacity = 100) {
14
+ this._buffer = new Array(capacity);
15
+ this._capacity = capacity;
16
+ this._head = 0;
17
+ this._size = 0;
18
+ }
19
+
20
+ push(item) {
21
+ this._buffer[this._head] = item;
22
+ this._head = (this._head + 1) % this._capacity;
23
+ if (this._size < this._capacity) this._size++;
24
+ }
25
+
26
+ toArray() {
27
+ if (this._size === 0) return [];
28
+ if (this._size < this._capacity) {
29
+ return this._buffer.slice(0, this._size);
30
+ }
31
+ // Wrap around — oldest first
32
+ return [
33
+ ...this._buffer.slice(this._head),
34
+ ...this._buffer.slice(0, this._head),
35
+ ];
36
+ }
37
+
38
+ get size() {
39
+ return this._size;
40
+ }
41
+
42
+ clear() {
43
+ this._buffer = new Array(this._capacity);
44
+ this._head = 0;
45
+ this._size = 0;
46
+ }
47
+ }
48
+
49
+ // ─── SubAgentRegistry ───────────────────────────────────────────────────────
50
+
51
+ export class SubAgentRegistry {
52
+ static _instance = null;
53
+
54
+ /**
55
+ * Get or create the singleton instance.
56
+ * @returns {SubAgentRegistry}
57
+ */
58
+ static getInstance() {
59
+ if (!SubAgentRegistry._instance) {
60
+ SubAgentRegistry._instance = new SubAgentRegistry();
61
+ }
62
+ return SubAgentRegistry._instance;
63
+ }
64
+
65
+ /**
66
+ * Reset singleton (for testing).
67
+ */
68
+ static resetInstance() {
69
+ SubAgentRegistry._instance = null;
70
+ }
71
+
72
+ constructor() {
73
+ /** @type {Map<string, import("./sub-agent-context.js").SubAgentContext>} */
74
+ this._active = new Map();
75
+
76
+ /** @type {RingBuffer} */
77
+ this._completed = new RingBuffer(100);
78
+
79
+ this._totalTokens = 0;
80
+ this._totalDurationMs = 0;
81
+ this._completedCount = 0;
82
+ }
83
+
84
+ /**
85
+ * Register an active sub-agent.
86
+ * @param {import("./sub-agent-context.js").SubAgentContext} subCtx
87
+ */
88
+ register(subCtx) {
89
+ this._active.set(subCtx.id, subCtx);
90
+ }
91
+
92
+ /**
93
+ * Mark a sub-agent as completed and move to history.
94
+ * @param {string} id - Sub-agent ID
95
+ * @param {object} result - { summary, artifacts, tokenCount, toolsUsed, iterationCount }
96
+ */
97
+ complete(id, result) {
98
+ const subCtx = this._active.get(id);
99
+ if (!subCtx) return;
100
+
101
+ this._active.delete(id);
102
+
103
+ const record = {
104
+ id: subCtx.id,
105
+ parentId: subCtx.parentId,
106
+ role: subCtx.role,
107
+ task: subCtx.task,
108
+ status: subCtx.status,
109
+ summary: result?.summary || "(no summary)",
110
+ toolsUsed: result?.toolsUsed || [],
111
+ tokenCount: result?.tokenCount || 0,
112
+ iterationCount: result?.iterationCount || 0,
113
+ createdAt: subCtx.createdAt,
114
+ completedAt: subCtx.completedAt || new Date().toISOString(),
115
+ durationMs: subCtx.completedAt
116
+ ? new Date(subCtx.completedAt) - new Date(subCtx.createdAt)
117
+ : Date.now() - new Date(subCtx.createdAt).getTime(),
118
+ };
119
+
120
+ this._completed.push(record);
121
+ this._totalTokens += record.tokenCount;
122
+ this._totalDurationMs += record.durationMs;
123
+ this._completedCount++;
124
+ }
125
+
126
+ /**
127
+ * Get all active sub-agents.
128
+ * @returns {Array<object>}
129
+ */
130
+ getActive() {
131
+ return [...this._active.values()].map((ctx) => ctx.toJSON());
132
+ }
133
+
134
+ /**
135
+ * Get completion history (most recent last).
136
+ * @returns {Array<object>}
137
+ */
138
+ getHistory() {
139
+ return this._completed.toArray();
140
+ }
141
+
142
+ /**
143
+ * Force-complete all sub-agents belonging to a session.
144
+ * Used by ws-session-manager on session close.
145
+ *
146
+ * @param {string} [sessionId] - If provided, only force-complete agents whose parentId matches
147
+ */
148
+ forceCompleteAll(sessionId) {
149
+ for (const [id, subCtx] of this._active) {
150
+ if (!sessionId || subCtx.parentId === sessionId) {
151
+ subCtx.forceComplete("session-closed");
152
+ this.complete(id, subCtx.result);
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Clean up stale entries older than maxAgeMs.
159
+ * @param {number} [maxAgeMs=600000] - Max age in ms (default: 10 minutes)
160
+ */
161
+ cleanup(maxAgeMs = 600000) {
162
+ const cutoff = Date.now() - maxAgeMs;
163
+ for (const [id, subCtx] of this._active) {
164
+ if (new Date(subCtx.createdAt).getTime() < cutoff) {
165
+ subCtx.forceComplete("timeout");
166
+ this.complete(id, subCtx.result);
167
+ }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Get registry statistics.
173
+ */
174
+ getStats() {
175
+ return {
176
+ active: this._active.size,
177
+ completed: this._completedCount,
178
+ historySize: this._completed.size,
179
+ totalTokens: this._totalTokens,
180
+ avgDurationMs:
181
+ this._completedCount > 0
182
+ ? Math.round(this._totalDurationMs / this._completedCount)
183
+ : 0,
184
+ };
185
+ }
186
+ }
@@ -19,6 +19,7 @@ import {
19
19
  listSessions as dbListSessions,
20
20
  } from "./session-manager.js";
21
21
  import { buildSystemPrompt } from "./agent-core.js";
22
+ import { SubAgentRegistry } from "./sub-agent-registry.js";
22
23
 
23
24
  /**
24
25
  * @typedef {object} Session
@@ -266,6 +267,13 @@ export class WSSessionManager {
266
267
  }
267
268
  }
268
269
 
270
+ // Force-complete any active sub-agents for this session
271
+ try {
272
+ SubAgentRegistry.getInstance().forceCompleteAll(sessionId);
273
+ } catch (_err) {
274
+ // Non-critical
275
+ }
276
+
269
277
  // Clean up plan manager listeners
270
278
  if (session.planManager) {
271
279
  session.planManager.removeAllListeners();
@@ -262,6 +262,9 @@ export async function startAgentRepl(options = {}) {
262
262
  ` ${chalk.cyan("/plan approve")} Approve and execute the plan`,
263
263
  );
264
264
  logger.log(` ${chalk.cyan("/plan reject")} Reject the plan`);
265
+ logger.log(
266
+ ` ${chalk.cyan("/sub-agents")} Show active/completed sub-agents`,
267
+ );
265
268
  logger.log(chalk.bold("\nCapabilities:"));
266
269
  logger.log(" Read, write, and edit files");
267
270
  logger.log(" Run shell commands (git, npm, etc.)");
@@ -275,6 +278,48 @@ export async function startAgentRepl(options = {}) {
275
278
  return;
276
279
  }
277
280
 
281
+ if (trimmed === "/sub-agents" || trimmed === "/subagents") {
282
+ try {
283
+ const { SubAgentRegistry } =
284
+ await import("../lib/sub-agent-registry.js");
285
+ const registry = SubAgentRegistry.getInstance();
286
+ const active = registry.getActive();
287
+ const history = registry.getHistory();
288
+ const stats = registry.getStats();
289
+
290
+ logger.log(chalk.bold("\nSub-Agent Registry:"));
291
+ logger.log(
292
+ ` Active: ${chalk.yellow(active.length)} Completed: ${chalk.green(stats.completed)} Tokens: ${stats.totalTokens} Avg Duration: ${stats.avgDurationMs}ms`,
293
+ );
294
+
295
+ if (active.length > 0) {
296
+ logger.log(chalk.bold("\n Active Sub-Agents:"));
297
+ for (const a of active) {
298
+ logger.log(
299
+ ` ${chalk.cyan(a.id)} [${a.role}] ${a.task.substring(0, 50)} (iter: ${a.iterationCount})`,
300
+ );
301
+ }
302
+ }
303
+
304
+ if (history.length > 0) {
305
+ logger.log(chalk.bold("\n Recent History (last 10):"));
306
+ for (const h of history.slice(-10)) {
307
+ const status =
308
+ h.status === "completed" ? chalk.green("✓") : chalk.red("✗");
309
+ logger.log(
310
+ ` ${status} ${chalk.dim(h.id)} [${h.role}] ${(h.summary || "").substring(0, 60)}`,
311
+ );
312
+ }
313
+ }
314
+
315
+ logger.log("");
316
+ } catch (_err) {
317
+ logger.log(chalk.dim("Sub-agent registry not available."));
318
+ }
319
+ prompt();
320
+ return;
321
+ }
322
+
278
323
  if (trimmed.startsWith("/model")) {
279
324
  const arg = trimmed.slice(6).trim();
280
325
  if (arg) {