tokenmaw 0.3.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.
Files changed (44) hide show
  1. package/README.md +150 -0
  2. package/agents/coordinator.md +13 -0
  3. package/agents/explorer.md +21 -0
  4. package/agents/implement.md +23 -0
  5. package/agents/main.md +25 -0
  6. package/agents/review.md +21 -0
  7. package/dist/backend.js +595 -0
  8. package/dist/cli.js +101 -0
  9. package/dist/config.js +155 -0
  10. package/dist/diff.js +45 -0
  11. package/dist/domain/agent.js +1 -0
  12. package/dist/fetch.js +110 -0
  13. package/dist/infra/file-snapshot.js +54 -0
  14. package/dist/infra/tools.js +1300 -0
  15. package/dist/markdown.js +274 -0
  16. package/dist/model-config.js +48 -0
  17. package/dist/policy.js +80 -0
  18. package/dist/responses.js +81 -0
  19. package/dist/runtime/agent-registry.js +139 -0
  20. package/dist/runtime/agent-runtime.js +993 -0
  21. package/dist/runtime/agent-store.js +152 -0
  22. package/dist/runtime/locks.js +46 -0
  23. package/dist/runtime/session-timeline.js +92 -0
  24. package/dist/tools/index.js +4 -0
  25. package/dist/tools/registry.js +51 -0
  26. package/dist/tools/types.js +1 -0
  27. package/dist/ui/clipboard.js +24 -0
  28. package/dist/ui/commands.js +20 -0
  29. package/dist/ui/composer-layout.js +31 -0
  30. package/dist/ui/fullscreen-tui.js +1405 -0
  31. package/dist/ui/markdown.js +81 -0
  32. package/dist/ui/syntax.js +17 -0
  33. package/dist/ui/tui-design.js +94 -0
  34. package/dist/ui/welcome.js +24 -0
  35. package/dist/version.js +4 -0
  36. package/docs/architecture-revision.md +281 -0
  37. package/package.json +47 -0
  38. package/skills/debugging.md +18 -0
  39. package/skills/git-workflow.md +14 -0
  40. package/skills/node-express.md +27 -0
  41. package/skills/python-flask.md +22 -0
  42. package/skills/react-component.md +24 -0
  43. package/skills/sql-database.md +18 -0
  44. package/skills/testing.md +12 -0
@@ -0,0 +1,993 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { resolve } from 'node:path';
3
+ import { chatStream } from '../backend.js';
4
+ import { executeTool, getToolPolicy, toolRegistry } from '../infra/tools.js';
5
+ import { AgentRegistry, matchesAgentSelector } from './agent-registry.js';
6
+ import { AgentRuntimeStore } from './agent-store.js';
7
+ import { FileLockManager } from './locks.js';
8
+ import { recordTimeline } from './session-timeline.js';
9
+ function now() {
10
+ return new Date().toISOString();
11
+ }
12
+ // ── Context compaction ───────────────────────────────────────────────────────
13
+ const COMPACT_SYSTEM_PROMPT = 'You are a context compaction assistant. Produce a faithful, information-dense digest of the archived conversation so a coding agent can continue the work without the original messages. Never invent facts; keep file paths, ids, decisions, and pending work exact.';
14
+ const DEFAULT_COMPACT_KEEP_RECENT = 12;
15
+ const COMPACT_MIN_ARCHIVED_MESSAGES = 6;
16
+ const AUTO_COMPACT_MIN_MESSAGES = 12;
17
+ const DEFAULT_AUTO_COMPACT_RATIO = 0.75;
18
+ const SUMMARY_MESSAGE_SNIPPET_LIMIT = 4000;
19
+ function messageSize(message) {
20
+ return String(message.content ?? '').length
21
+ + JSON.stringify(message.tool_calls ?? []).length
22
+ + JSON.stringify(message.responseItems ?? []).length;
23
+ }
24
+ function formatMessageForSummary(index, message) {
25
+ const header = `[message ${index + 1}] ${message.role}`;
26
+ const parts = [];
27
+ if (message.content)
28
+ parts.push(String(message.content).slice(0, SUMMARY_MESSAGE_SNIPPET_LIMIT));
29
+ if (message.tool_calls?.length) {
30
+ parts.push(message.tool_calls.map((call) => `tool call ${call.function.name}(${String(JSON.stringify(call.function.arguments ?? {})).slice(0, 2000)})`).join('\n'));
31
+ }
32
+ if (message.role === 'tool' && message.tool_use_id)
33
+ parts.push(`(tool result for ${message.tool_use_id})`);
34
+ return parts.length ? `${header}\n${parts.join('\n')}` : header;
35
+ }
36
+ function cloneInstance(instance) {
37
+ return {
38
+ ...instance,
39
+ messages: instance.messages.map((message) => ({
40
+ ...message,
41
+ tool_calls: message.tool_calls?.map((call) => ({
42
+ ...call,
43
+ function: { ...call.function, arguments: { ...call.function.arguments } },
44
+ })),
45
+ })),
46
+ mailbox: instance.mailbox.map((message) => ({ ...message })),
47
+ childInstanceIds: [...instance.childInstanceIds],
48
+ };
49
+ }
50
+ function cloneSession(session) {
51
+ return { ...session, timeline: session.timeline?.map(entry => ({ ...entry })), messages: session.messages.map((message) => ({ ...message })), instanceIds: [...session.instanceIds] };
52
+ }
53
+ function parseStringList(value) {
54
+ if (Array.isArray(value))
55
+ return value.filter((item) => typeof item === 'string');
56
+ if (typeof value !== 'string')
57
+ return [];
58
+ try {
59
+ const parsed = JSON.parse(value);
60
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : [value];
61
+ }
62
+ catch {
63
+ return value.split(',').map((item) => item.trim()).filter(Boolean);
64
+ }
65
+ }
66
+ function toolDefinition(name, description, properties, required) {
67
+ return { type: 'function', function: { name, description, parameters: { type: 'object', properties, required } } };
68
+ }
69
+ const AGENT_TOOL_DEFINITIONS = [
70
+ toolDefinition('spawn_agent', 'Start an allowed agent instance asynchronously and return its instance id.', {
71
+ agent: { type: 'string', description: 'Agent id from the available agent catalog.' },
72
+ message: { type: 'string', description: 'Self-contained request for the agent.' },
73
+ }, ['agent', 'message']),
74
+ toolDefinition('send_agent', 'Send a follow-up, correction, or result to an existing related agent instance.', {
75
+ instance_id: { type: 'string', description: 'Target agent instance id.' },
76
+ message: { type: 'string', description: 'Message to deliver.' },
77
+ }, ['instance_id', 'message']),
78
+ toolDefinition('wait_agent', 'Wait for one or more related agent instances to become idle, fail, or be cancelled.', {
79
+ instance_ids: { type: 'array', description: 'Agent instance ids.', items: { type: 'string' } },
80
+ timeout_ms: { type: 'number', description: 'Maximum wait, default 60000 and maximum 300000.' },
81
+ }, ['instance_ids']),
82
+ toolDefinition('cancel_agent', 'Cancel a related agent instance.', {
83
+ instance_id: { type: 'string', description: 'Target agent instance id.' },
84
+ }, ['instance_id']),
85
+ ];
86
+ const COMPACT_TOOL_DEFINITIONS = [
87
+ toolDefinition('compact_context', 'Compact the conversation context of this agent (default) or a descendant agent instance: older messages are replaced by a model-generated digest, the original messages are archived, and the digest stays in context. Use after completing a major milestone to free context for the next phase.', {
88
+ instance_id: { type: 'string', description: 'Target agent instance id. Omit to compact your own context.' },
89
+ focus: { type: 'string', description: 'What the digest should emphasize (goals, decisions, file changes, pending work). Omit for a general digest.' },
90
+ keep_recent: { type: 'number', description: 'Approximate number of recent messages to keep verbatim. Default 12.' },
91
+ }, []),
92
+ toolDefinition('search_history', 'Search the archived (compacted-away) context of this agent (default) or a descendant agent instance. Use this to recall details that were summarized out of context.', {
93
+ query: { type: 'string', description: 'Case-insensitive text to search for.' },
94
+ instance_id: { type: 'string', description: 'Target agent instance id. Omit to search your own archives.' },
95
+ limit: { type: 'number', description: 'Maximum number of matches to return. Default 8.' },
96
+ }, ['query']),
97
+ ];
98
+ export class AgentRuntime {
99
+ registry;
100
+ store;
101
+ workspaceRoot;
102
+ resolveModel;
103
+ modelStream;
104
+ maxConcurrentTurns;
105
+ maxAgentDepth;
106
+ defaultModel;
107
+ sessions = new Map();
108
+ instances = new Map();
109
+ subscribers = new Set();
110
+ queue = [];
111
+ queued = new Set();
112
+ activeTurns = new Set();
113
+ running = new Set();
114
+ controllers = new Map();
115
+ idleWaiters = new Set();
116
+ fileLocks = new FileLockManager();
117
+ ready;
118
+ shuttingDown = false;
119
+ constructor(options) {
120
+ this.workspaceRoot = resolve(options.workspaceRoot ?? process.cwd());
121
+ this.registry = options.registry ?? new AgentRegistry({ workspaceRoot: this.workspaceRoot });
122
+ this.store = options.store ?? new AgentRuntimeStore();
123
+ this.resolveModel = options.resolveModel;
124
+ this.defaultModel = options.defaultModel;
125
+ this.modelStream = options.modelStream ?? ((config, system, messages, tools, signal) => (chatStream(config, system, messages, tools, signal)));
126
+ this.maxConcurrentTurns = Math.max(1, options.maxConcurrentTurns ?? Number(process.env.AGENT_MAX_CONCURRENT_TURNS ?? 4));
127
+ this.maxAgentDepth = Math.max(1, options.maxAgentDepth ?? Number(process.env.AGENT_MAX_DEPTH ?? 4));
128
+ this.ready = Promise.all([this.registry.load(), this.store.init()]).then(() => this.validateSpecs());
129
+ }
130
+ whenReady() {
131
+ return this.ready;
132
+ }
133
+ subscribe(listener) {
134
+ this.subscribers.add(listener);
135
+ return () => this.subscribers.delete(listener);
136
+ }
137
+ emit(event) {
138
+ const sessionId = 'sessionId' in event ? event.sessionId : 'instance' in event ? event.instance.sessionId : 'instanceId' in event && event.instanceId ? this.instances.get(event.instanceId)?.sessionId : undefined;
139
+ const session = sessionId ? this.sessions.get(sessionId) : undefined;
140
+ if (session)
141
+ recordTimeline(session, event);
142
+ for (const listener of this.subscribers) {
143
+ try {
144
+ listener(event);
145
+ }
146
+ catch { /* subscribers cannot break the runtime */ }
147
+ }
148
+ }
149
+ setDefaultModel(alias) {
150
+ this.defaultModel = alias;
151
+ }
152
+ async setSessionDefaultModel(sessionId, alias) {
153
+ if (!this.sessions.has(sessionId))
154
+ await this.openSession(sessionId);
155
+ const session = this.sessions.get(sessionId);
156
+ session.defaultModel = alias;
157
+ session.updatedAt = now();
158
+ await this.persistSession(sessionId);
159
+ }
160
+ async reloadAgentSpecs() {
161
+ await this.registry.load();
162
+ this.validateSpecs();
163
+ }
164
+ listAgentSpecs() {
165
+ return this.registry.list();
166
+ }
167
+ async openSession(sessionId = `session-${Date.now()}`) {
168
+ await this.ready;
169
+ const current = this.sessions.get(sessionId);
170
+ if (current)
171
+ return cloneSession(current);
172
+ const persisted = await this.store.load(sessionId);
173
+ if (persisted) {
174
+ const session = persisted.session;
175
+ this.sessions.set(sessionId, session);
176
+ for (const instance of persisted.instances) {
177
+ if (instance.status === 'running' || instance.status === 'queued' || instance.status === 'waiting') {
178
+ instance.status = instance.mailbox.some((message) => message.status === 'pending') ? 'queued' : 'idle';
179
+ instance.activeTurnId = undefined;
180
+ }
181
+ this.instances.set(instance.instanceId, instance);
182
+ }
183
+ for (const instance of persisted.instances.filter((item) => item.status === 'queued'))
184
+ this.enqueue(instance.instanceId);
185
+ this.emit({ type: 'session_opened', session: cloneSession(session) });
186
+ return cloneSession(session);
187
+ }
188
+ if (!this.registry.get('main'))
189
+ throw new Error('No main agent spec found');
190
+ const createdAt = now();
191
+ const main = this.newInstance(sessionId, 'main', undefined, 0, createdAt);
192
+ const session = {
193
+ sessionId,
194
+ mainInstanceId: main.instanceId,
195
+ defaultModel: this.defaultModel,
196
+ messages: [],
197
+ instanceIds: [main.instanceId],
198
+ createdAt,
199
+ updatedAt: createdAt,
200
+ };
201
+ this.sessions.set(sessionId, session);
202
+ this.instances.set(main.instanceId, main);
203
+ await this.persistSession(sessionId);
204
+ this.emit({ type: 'session_opened', session: cloneSession(session) });
205
+ this.emit({ type: 'instance_created', instance: cloneInstance(main) });
206
+ return cloneSession(session);
207
+ }
208
+ getSession(sessionId) {
209
+ const session = this.sessions.get(sessionId);
210
+ return session ? cloneSession(session) : undefined;
211
+ }
212
+ getInstance(instanceId) {
213
+ const instance = this.instances.get(instanceId);
214
+ return instance ? cloneInstance(instance) : undefined;
215
+ }
216
+ listInstances(sessionId) {
217
+ const session = this.sessions.get(sessionId);
218
+ if (!session)
219
+ return [];
220
+ return session.instanceIds.map((id) => this.instances.get(id)).filter((item) => Boolean(item)).map(cloneInstance);
221
+ }
222
+ async listSessions() {
223
+ await this.ready;
224
+ return this.store.list();
225
+ }
226
+ async removeSession(sessionId) {
227
+ const session = this.sessions.get(sessionId);
228
+ for (const id of session?.instanceIds ?? []) {
229
+ this.controllers.get(id)?.abort('Session removed');
230
+ this.instances.delete(id);
231
+ this.queued.delete(id);
232
+ }
233
+ this.sessions.delete(sessionId);
234
+ await this.store.remove(sessionId);
235
+ }
236
+ async clearSession(sessionId) {
237
+ if (!this.sessions.has(sessionId))
238
+ await this.openSession(sessionId);
239
+ await this.cancelSession(sessionId);
240
+ const session = this.sessions.get(sessionId);
241
+ session.messages = [];
242
+ session.timeline = [];
243
+ session.updatedAt = now();
244
+ const main = this.instances.get(session.mainInstanceId);
245
+ if (main) {
246
+ this.controllers.get(main.instanceId)?.abort('Conversation cleared');
247
+ main.messages = [];
248
+ main.mailbox = [];
249
+ main.status = 'idle';
250
+ main.compactionCount = 0;
251
+ main.lastError = undefined;
252
+ main.lastOutput = undefined;
253
+ main.updatedAt = now();
254
+ }
255
+ await this.persistSession(sessionId);
256
+ }
257
+ async cancelSession(sessionId) {
258
+ const session = this.sessions.get(sessionId);
259
+ if (!session)
260
+ return;
261
+ for (const id of session.instanceIds) {
262
+ const instance = this.instances.get(id);
263
+ if (!instance)
264
+ continue;
265
+ this.controllers.get(id)?.abort('Stopped by user');
266
+ this.queued.delete(id);
267
+ instance.mailbox.forEach((message) => { message.status = 'delivered'; });
268
+ instance.status = 'cancelled';
269
+ instance.activeTurnId = undefined;
270
+ instance.updatedAt = now();
271
+ this.emit({ type: 'instance_updated', instance: cloneInstance(instance) });
272
+ }
273
+ await this.persistSession(sessionId);
274
+ this.notifyIdleWaiters();
275
+ }
276
+ async submitMessage(sessionId, content) {
277
+ const text = content.trim();
278
+ if (!text)
279
+ throw new Error('Message cannot be empty');
280
+ await this.openSession(sessionId);
281
+ const session = this.sessions.get(sessionId);
282
+ const main = this.instances.get(session.mainInstanceId);
283
+ const turnId = randomUUID();
284
+ const message = { messageId: randomUUID(), role: 'user', content: text, createdAt: now(), turnId };
285
+ session.messages.push(message);
286
+ session.updatedAt = message.createdAt;
287
+ if (main.status === 'running' || main.status === 'waiting') {
288
+ this.controllers.get(main.instanceId)?.abort('Superseded by a newer user message');
289
+ }
290
+ if (main.status === 'cancelled')
291
+ main.status = 'idle';
292
+ this.deliver(main, text, undefined, turnId);
293
+ await this.persistSession(sessionId);
294
+ this.emit({ type: 'user_message', sessionId, message: { ...message } });
295
+ this.enqueue(main.instanceId);
296
+ return turnId;
297
+ }
298
+ async spawnAgent(fromInstanceId, agentId, message) {
299
+ const parent = this.instances.get(fromInstanceId);
300
+ if (!parent)
301
+ throw new Error(`Agent instance ${fromInstanceId} not found`);
302
+ const parentSpec = this.registry.get(parent.agentId);
303
+ if (!parentSpec || !this.registry.canCall(parentSpec, agentId))
304
+ throw new Error(`Agent ${parent.agentId} cannot call ${agentId}`);
305
+ if (!message.trim())
306
+ throw new Error('Agent message cannot be empty');
307
+ if (parent.depth + 1 > this.maxAgentDepth)
308
+ throw new Error(`Maximum agent depth ${this.maxAgentDepth} exceeded`);
309
+ const ancestors = this.ancestorAgentIds(parent);
310
+ if (ancestors.has(agentId))
311
+ throw new Error(`Agent call cycle rejected: ${agentId} already exists in the ancestor chain`);
312
+ const session = this.sessions.get(parent.sessionId);
313
+ const child = this.newInstance(parent.sessionId, agentId, parent.instanceId, parent.depth + 1);
314
+ this.instances.set(child.instanceId, child);
315
+ parent.childInstanceIds.push(child.instanceId);
316
+ parent.updatedAt = now();
317
+ session.instanceIds.push(child.instanceId);
318
+ session.updatedAt = now();
319
+ this.deliver(child, message, parent.instanceId);
320
+ await this.persistSession(session.sessionId);
321
+ this.emit({ type: 'instance_created', instance: cloneInstance(child) });
322
+ this.emit({ type: 'instance_updated', instance: cloneInstance(parent) });
323
+ this.enqueue(child.instanceId);
324
+ return child.instanceId;
325
+ }
326
+ async sendAgent(fromInstanceId, targetInstanceId, message) {
327
+ const from = this.instances.get(fromInstanceId);
328
+ const target = this.instances.get(targetInstanceId);
329
+ if (!from || !target || from.sessionId !== target.sessionId)
330
+ throw new Error('Related agent instance not found');
331
+ if (!message.trim())
332
+ throw new Error('Agent message cannot be empty');
333
+ const directlyRelated = from.parentInstanceId === target.instanceId || target.parentInstanceId === from.instanceId;
334
+ const canCallTarget = this.registry.canCall(from.agentId, target.agentId);
335
+ if (!directlyRelated && !canCallTarget)
336
+ throw new Error(`Agent ${from.agentId} cannot message ${target.agentId}`);
337
+ if (target.status === 'cancelled')
338
+ throw new Error('Target agent instance is cancelled');
339
+ if (target.status === 'running' || target.status === 'waiting')
340
+ this.controllers.get(target.instanceId)?.abort('Agent sent a newer message');
341
+ this.deliver(target, message, from.instanceId);
342
+ await this.persistSession(target.sessionId);
343
+ this.enqueue(target.instanceId);
344
+ }
345
+ async cancelAgent(requesterId, targetId) {
346
+ const requester = this.instances.get(requesterId);
347
+ const target = this.instances.get(targetId);
348
+ if (!requester || !target || requester.sessionId !== target.sessionId)
349
+ throw new Error('Related agent instance not found');
350
+ const related = requester.instanceId === target.parentInstanceId || requester.parentInstanceId === target.instanceId;
351
+ if (!related && !this.registry.canCall(requester.agentId, target.agentId))
352
+ throw new Error('Cannot cancel unrelated agent instance');
353
+ this.controllers.get(targetId)?.abort('Cancelled by related agent');
354
+ target.status = 'cancelled';
355
+ target.activeTurnId = undefined;
356
+ target.updatedAt = now();
357
+ this.queued.delete(targetId);
358
+ await this.persistSession(target.sessionId);
359
+ this.emit({ type: 'instance_updated', instance: cloneInstance(target) });
360
+ this.notifyIdleWaiters();
361
+ }
362
+ async waitForAgents(requesterId, ids, timeoutMs = 60_000) {
363
+ const requester = this.instances.get(requesterId);
364
+ if (!requester)
365
+ throw new Error('Requesting agent instance not found');
366
+ const targets = ids.map((id) => this.instances.get(id));
367
+ if (targets.some((target) => !target || target.sessionId !== requester.sessionId))
368
+ throw new Error('Related agent instance not found');
369
+ const done = () => targets.every((target) => target && ['idle', 'failed', 'cancelled'].includes(target.status));
370
+ const signal = this.controllers.get(requesterId)?.signal;
371
+ if (!done()) {
372
+ requester.status = 'waiting';
373
+ this.emit({ type: 'instance_updated', instance: cloneInstance(requester) });
374
+ // Waiting on mailboxes consumes no model/tool capacity. Yield this slot
375
+ // so all requested siblings can run even when their count reaches the
376
+ // global concurrency limit.
377
+ const yieldedCapacity = this.running.delete(requester.instanceId);
378
+ if (yieldedCapacity)
379
+ this.pump();
380
+ await new Promise((resolveWait) => {
381
+ const finish = () => {
382
+ clearTimeout(timeout);
383
+ this.idleWaiters.delete(check);
384
+ signal?.removeEventListener('abort', finish);
385
+ resolveWait();
386
+ };
387
+ const timeout = setTimeout(finish, Math.min(Math.max(timeoutMs, 100), 300_000));
388
+ const check = () => {
389
+ if (!done())
390
+ return;
391
+ finish();
392
+ };
393
+ this.idleWaiters.add(check);
394
+ signal?.addEventListener('abort', finish, { once: true });
395
+ if (signal?.aborted)
396
+ finish();
397
+ });
398
+ if (signal?.aborted)
399
+ return 'Wait cancelled.';
400
+ if (yieldedCapacity) {
401
+ const hasCapacity = () => !requester.parentInstanceId || this.backgroundRunning() < this.maxConcurrentTurns;
402
+ while (!hasCapacity() && !signal?.aborted) {
403
+ await new Promise((resolveCapacity) => {
404
+ const finish = () => {
405
+ this.idleWaiters.delete(check);
406
+ signal?.removeEventListener('abort', finish);
407
+ resolveCapacity();
408
+ };
409
+ const check = () => {
410
+ if (hasCapacity())
411
+ finish();
412
+ };
413
+ this.idleWaiters.add(check);
414
+ signal?.addEventListener('abort', finish, { once: true });
415
+ if (signal?.aborted)
416
+ finish();
417
+ });
418
+ }
419
+ if (signal?.aborted)
420
+ return 'Wait cancelled.';
421
+ this.running.add(requester.instanceId);
422
+ }
423
+ if (requester.status === 'waiting')
424
+ requester.status = 'running';
425
+ }
426
+ const waited = new Set(ids);
427
+ for (const message of requester.mailbox) {
428
+ if (message.status === 'pending' && message.fromInstanceId && waited.has(message.fromInstanceId)) {
429
+ message.status = 'delivered';
430
+ }
431
+ }
432
+ return JSON.stringify(targets.map((target) => ({
433
+ instanceId: target.instanceId,
434
+ agentId: target.agentId,
435
+ status: target.status,
436
+ output: target.lastOutput,
437
+ error: target.lastError,
438
+ })));
439
+ }
440
+ async waitForIdle(sessionId, timeoutMs = 300_000) {
441
+ const idle = () => {
442
+ const session = this.sessions.get(sessionId);
443
+ return !session || session.instanceIds.every((id) => {
444
+ const instance = this.instances.get(id);
445
+ return instance && ['idle', 'failed', 'cancelled'].includes(instance.status)
446
+ && !this.running.has(id) && !this.queued.has(id);
447
+ });
448
+ };
449
+ if (idle())
450
+ return;
451
+ await new Promise((resolveWait, reject) => {
452
+ const timeout = setTimeout(() => { this.idleWaiters.delete(check); reject(new Error('Timed out waiting for agent runtime')); }, timeoutMs);
453
+ const check = () => {
454
+ if (!idle())
455
+ return;
456
+ clearTimeout(timeout);
457
+ this.idleWaiters.delete(check);
458
+ resolveWait();
459
+ };
460
+ this.idleWaiters.add(check);
461
+ });
462
+ }
463
+ async shutdown() {
464
+ this.shuttingDown = true;
465
+ for (const controller of this.controllers.values())
466
+ controller.abort('Runtime shutdown');
467
+ for (const sessionId of this.sessions.keys())
468
+ await this.persistSession(sessionId);
469
+ await this.store.flush();
470
+ }
471
+ newInstance(sessionId, agentId, parentInstanceId, depth = 0, createdAt = now()) {
472
+ if (!this.registry.get(agentId))
473
+ throw new Error(`Agent spec ${agentId} not found`);
474
+ return {
475
+ instanceId: randomUUID(), sessionId, agentId, parentInstanceId, depth,
476
+ status: 'idle', messages: [], mailbox: [], childInstanceIds: [], createdAt, updatedAt: createdAt,
477
+ };
478
+ }
479
+ validateSpecs() {
480
+ const specs = this.registry.list();
481
+ for (const spec of specs) {
482
+ for (const tool of spec.tools) {
483
+ if (tool !== '*' && !toolRegistry.has(tool))
484
+ throw new Error(`Agent spec ${spec.source} references unknown tool "${tool}"`);
485
+ }
486
+ for (const selector of spec.agents) {
487
+ if (selector !== '*' && !selector.endsWith('/*')
488
+ && !specs.some((candidate) => candidate.id !== spec.id && matchesAgentSelector(candidate.id, selector))) {
489
+ throw new Error(`Agent spec ${spec.source} references an agent selector with no matches: "${selector}"`);
490
+ }
491
+ }
492
+ }
493
+ }
494
+ ancestorAgentIds(instance) {
495
+ const ids = new Set([instance.agentId]);
496
+ let parentId = instance.parentInstanceId;
497
+ while (parentId) {
498
+ const parent = this.instances.get(parentId);
499
+ if (!parent)
500
+ break;
501
+ ids.add(parent.agentId);
502
+ parentId = parent.parentInstanceId;
503
+ }
504
+ return ids;
505
+ }
506
+ deliver(target, content, fromInstanceId, turnId) {
507
+ const message = { messageId: randomUUID(), fromInstanceId, turnId, content, createdAt: now(), status: 'pending' };
508
+ target.mailbox.push(message);
509
+ target.updatedAt = message.createdAt;
510
+ if (target.status !== 'cancelled' && !this.activeTurns.has(target.instanceId))
511
+ target.status = 'queued';
512
+ this.emit({ type: 'mailbox_message', instanceId: target.instanceId, message: { ...message } });
513
+ this.emit({ type: 'instance_updated', instance: cloneInstance(target) });
514
+ }
515
+ enqueue(instanceId) {
516
+ if (this.shuttingDown || this.activeTurns.has(instanceId) || this.queued.has(instanceId))
517
+ return;
518
+ const instance = this.instances.get(instanceId);
519
+ if (!instance || instance.status === 'cancelled')
520
+ return;
521
+ this.queue.push(instanceId);
522
+ this.queued.add(instanceId);
523
+ queueMicrotask(() => this.pump());
524
+ }
525
+ backgroundRunning() {
526
+ return [...this.running].filter((id) => this.instances.get(id)?.parentInstanceId).length;
527
+ }
528
+ pump() {
529
+ while (!this.shuttingDown && this.queue.length) {
530
+ // User-facing entry instances have a separate lane: busy workers must
531
+ // never keep a new user message queued behind long-running work.
532
+ const mainIndex = this.queue.findIndex((id) => {
533
+ const candidate = this.instances.get(id);
534
+ return candidate && !candidate.parentInstanceId;
535
+ });
536
+ if (mainIndex < 0 && this.backgroundRunning() >= this.maxConcurrentTurns)
537
+ break;
538
+ const id = this.queue.splice(mainIndex >= 0 ? mainIndex : 0, 1)[0];
539
+ this.queued.delete(id);
540
+ const instance = this.instances.get(id);
541
+ if (!instance || instance.status === 'cancelled' || this.activeTurns.has(id))
542
+ continue;
543
+ this.activeTurns.add(id);
544
+ this.running.add(id);
545
+ void this.runTurn(instance).finally(() => {
546
+ this.activeTurns.delete(id);
547
+ this.running.delete(id);
548
+ this.controllers.delete(id);
549
+ if (instance.status !== 'cancelled' && instance.mailbox.some((message) => message.status === 'pending'))
550
+ this.enqueue(id);
551
+ this.notifyIdleWaiters();
552
+ this.pump();
553
+ });
554
+ }
555
+ }
556
+ absorbMailbox(instance) {
557
+ const pending = instance.mailbox.filter((message) => message.status === 'pending');
558
+ for (const message of pending) {
559
+ message.status = 'delivered';
560
+ let prefix = 'User message';
561
+ if (message.fromInstanceId) {
562
+ const from = this.instances.get(message.fromInstanceId);
563
+ prefix = from ? `Message from ${from.agentId} (${from.instanceId.slice(0, 8)})` : 'Message from another agent';
564
+ }
565
+ instance.messages.push({ role: 'user', content: `${prefix}:\n${message.content}` });
566
+ }
567
+ }
568
+ systemPrompt(instance, spec) {
569
+ const catalog = this.registry.allowedAgents(spec);
570
+ const relatedInstances = this.listInstances(instance.sessionId)
571
+ .filter((candidate) => candidate.instanceId !== instance.instanceId && candidate.status !== 'cancelled')
572
+ .map((candidate) => `- ${candidate.agentId} (${candidate.instanceId}): ${candidate.status}${candidate.lastOutput ? ` — ${candidate.lastOutput.slice(0, 180)}` : ''}`);
573
+ return [
574
+ spec.instructions,
575
+ '',
576
+ 'Runtime contract:',
577
+ `- You are agent "${spec.id}" in workspace ${this.workspaceRoot}.`,
578
+ '- Decide your own next step from your spec, messages, tools, and available agent catalog.',
579
+ '- Do not invent agent ids. Agent calls outside the catalog are rejected.',
580
+ '- Keep agent messages self-contained because child agents do not receive your full conversation.',
581
+ instance.parentInstanceId
582
+ ? '- Your output is private to the parent agent. Report concise progress and results; never address the end user directly.'
583
+ : '- You are the session entry instance. Your natural-language output is shown directly to the user.',
584
+ catalog.length
585
+ ? `Available agents:\n${catalog.map((agent) => `- ${agent.id}: ${agent.description}`).join('\n')}`
586
+ : 'Available agents: none.',
587
+ relatedInstances.length
588
+ ? `Existing instances in this session:\n${relatedInstances.join('\n')}`
589
+ : 'Existing instances in this session: none.',
590
+ ].join('\n');
591
+ }
592
+ toolsFor(instance, spec) {
593
+ const requested = spec.tools.includes('*')
594
+ ? toolRegistry.definitions().map((definition) => definition.function.name)
595
+ : spec.tools;
596
+ const tools = requested
597
+ .map((name) => toolRegistry.get(name)?.definition)
598
+ .filter((definition) => Boolean(definition));
599
+ if (spec.agents.length > 0 && this.registry.allowedAgents(spec).length > 0) {
600
+ tools.push(...AGENT_TOOL_DEFINITIONS);
601
+ }
602
+ tools.push(...COMPACT_TOOL_DEFINITIONS);
603
+ return tools;
604
+ }
605
+ trimMessages(messages, config) {
606
+ const budgetChars = this.contextBudgetChars(config);
607
+ let total = 0;
608
+ const kept = [];
609
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
610
+ const message = messages[index];
611
+ const size = messageSize(message);
612
+ if (kept.length && total + size > budgetChars)
613
+ break;
614
+ kept.unshift(message);
615
+ total += size;
616
+ }
617
+ // Tool results must never be sent without their assistant tool-call message.
618
+ while (kept[0]?.role === 'tool')
619
+ kept.shift();
620
+ return kept;
621
+ }
622
+ contextBudgetChars(config) {
623
+ return Math.max(16_000, Math.floor((config.contextWindow ?? 131_072) * 4 * 0.72));
624
+ }
625
+ shouldAutoCompact(messages, config) {
626
+ if (messages.length < AUTO_COMPACT_MIN_MESSAGES)
627
+ return false;
628
+ const size = messages.reduce((total, message) => total + messageSize(message), 0);
629
+ const ratio = Number(process.env.AGENT_AUTO_COMPACT_RATIO ?? DEFAULT_AUTO_COMPACT_RATIO);
630
+ return size > this.contextBudgetChars(config) * ratio;
631
+ }
632
+ /**
633
+ * Index where the kept tail must start: at or after `keepRecent` messages back,
634
+ * advanced to the next `user` message so the tail never begins with a tool
635
+ * result detached from its assistant tool-call message.
636
+ */
637
+ compactBoundary(messages, keepRecent) {
638
+ const start = Math.max(0, messages.length - Math.max(1, keepRecent));
639
+ for (let index = start; index < messages.length; index += 1) {
640
+ if (messages[index].role === 'user')
641
+ return index;
642
+ }
643
+ return messages.length;
644
+ }
645
+ async compactInstanceMessages(instance, config, options, signal) {
646
+ delete instance.pendingCompact;
647
+ const keepRecent = Math.max(1, Math.floor(options.keepRecent ?? DEFAULT_COMPACT_KEEP_RECENT));
648
+ const boundary = this.compactBoundary(instance.messages, keepRecent);
649
+ const archived = instance.messages.slice(0, boundary);
650
+ const tail = instance.messages.slice(boundary);
651
+ if (archived.length < COMPACT_MIN_ARCHIVED_MESSAGES) {
652
+ return { compacted: false, detail: `Context is too short to compact (need at least ${COMPACT_MIN_ARCHIVED_MESSAGES} older messages before the recent tail).` };
653
+ }
654
+ const charsBefore = instance.messages.reduce((total, message) => total + messageSize(message), 0);
655
+ const transcript = archived
656
+ .map((message, index) => formatMessageForSummary(index, message))
657
+ .join('\n');
658
+ const request = [
659
+ 'Summarize the following earlier conversation for a coding agent that will continue the work with only this digest in context.',
660
+ 'Preserve: the user goals, decisions made, files/paths touched with what changed, important tool results, open questions, and pending work.',
661
+ options.focus?.trim() ? `Emphasize: ${options.focus.trim()}` : '',
662
+ 'Write the digest in the same language as the conversation. Be thorough but concise.',
663
+ '',
664
+ '<archived-conversation>',
665
+ transcript,
666
+ '</archived-conversation>',
667
+ ].filter(Boolean).join('\n');
668
+ let summary = '';
669
+ for await (const chunk of this.modelStream(config, COMPACT_SYSTEM_PROMPT, [{ role: 'user', content: request }], [], signal)) {
670
+ if (signal?.aborted)
671
+ return { compacted: false, detail: 'Compaction cancelled.' };
672
+ if (chunk.content)
673
+ summary += chunk.content;
674
+ }
675
+ summary = summary.trim();
676
+ if (!summary)
677
+ return { compacted: false, detail: 'Compaction produced no summary; context left unchanged.' };
678
+ const seq = (instance.compactionCount ?? 0) + 1;
679
+ await this.store.saveArchive(instance.sessionId, instance.instanceId, seq, JSON.parse(JSON.stringify(archived)));
680
+ const digest = {
681
+ role: 'user',
682
+ content: [
683
+ `<context-digest instance="${instance.instanceId}" archive-seq="${seq}">`,
684
+ 'Earlier conversation was compacted. The original messages are archived and searchable with the search_history tool.',
685
+ options.focus?.trim() ? `Focus requested: ${options.focus.trim()}` : '',
686
+ '',
687
+ summary,
688
+ '</context-digest>',
689
+ ].filter((line) => line !== undefined).join('\n'),
690
+ };
691
+ instance.messages = [digest, ...tail];
692
+ instance.compactionCount = seq;
693
+ instance.updatedAt = now();
694
+ const charsAfter = instance.messages.reduce((total, message) => total + messageSize(message), 0);
695
+ this.emit({
696
+ type: 'context_compacted',
697
+ sessionId: instance.sessionId,
698
+ instanceId: instance.instanceId,
699
+ agentId: instance.agentId,
700
+ reason: options.reason,
701
+ archivedMessages: archived.length,
702
+ charsBefore,
703
+ charsAfter,
704
+ });
705
+ this.emit({ type: 'instance_updated', instance: cloneInstance(instance) });
706
+ await this.persistSession(instance.sessionId);
707
+ return {
708
+ compacted: true,
709
+ detail: `Archived ${archived.length} older messages (archive seq ${seq}) and replaced them with a digest. Context shrank from ${charsBefore} to ${charsAfter} chars. Use search_history to recall archived details.`,
710
+ };
711
+ }
712
+ resolveConfigFor(instance) {
713
+ const session = this.sessions.get(instance.sessionId);
714
+ const spec = this.registry.get(instance.agentId);
715
+ return { ...this.resolveModel(spec?.model ?? session.defaultModel ?? this.defaultModel), sessionId: instance.sessionId };
716
+ }
717
+ isSelfOrDescendant(fromInstanceId, targetInstanceId) {
718
+ if (fromInstanceId === targetInstanceId)
719
+ return true;
720
+ let current = this.instances.get(targetInstanceId);
721
+ while (current?.parentInstanceId) {
722
+ if (current.parentInstanceId === fromInstanceId)
723
+ return true;
724
+ current = this.instances.get(current.parentInstanceId);
725
+ }
726
+ return false;
727
+ }
728
+ /** Compact an instance on demand. Used by /compact and the compact_context tool for idle targets. */
729
+ async compactInstance(instanceId, options = {}) {
730
+ await this.ready;
731
+ const instance = this.instances.get(instanceId);
732
+ if (!instance)
733
+ throw new Error(`Agent instance ${instanceId} not found`);
734
+ if (instance.status === 'running' || instance.status === 'waiting')
735
+ throw new Error('Agent is running. Stop it first (/cancel) or compact a descendant agent instead.');
736
+ const result = await this.compactInstanceMessages(instance, this.resolveConfigFor(instance), { ...options, reason: options.reason ?? 'manual' });
737
+ return result.detail;
738
+ }
739
+ /** Search archived (compacted-away) messages of an instance. */
740
+ async searchArchivedContext(instanceId, query, limit = 8) {
741
+ await this.ready;
742
+ const instance = this.instances.get(instanceId);
743
+ if (!instance)
744
+ throw new Error(`Agent instance ${instanceId} not found`);
745
+ const needle = query.trim().toLowerCase();
746
+ if (!needle)
747
+ return 'Search query is empty.';
748
+ const archives = await this.store.loadArchives(instance.sessionId, instance.instanceId);
749
+ if (!archives.length)
750
+ return 'No archived context yet — this instance has never been compacted.';
751
+ const matches = [];
752
+ for (const archive of archives) {
753
+ for (let index = 0; index < archive.messages.length; index += 1) {
754
+ const message = archive.messages[index];
755
+ const haystack = formatMessageForSummary(index, message).toLowerCase();
756
+ const at = haystack.indexOf(needle);
757
+ if (at === -1)
758
+ continue;
759
+ const label = message.role === 'tool' ? `tool result${message.tool_use_id ? ` for ${message.tool_use_id}` : ''}` : message.role;
760
+ const content = String(message.content ?? (message.tool_calls ? JSON.stringify(message.tool_calls) : ''));
761
+ const start = Math.max(0, Math.min(at - 120, content.length - 320));
762
+ const snippet = `${start > 0 ? '…' : ''}${content.slice(start, start + 320)}${content.length > start + 320 ? '…' : ''}`;
763
+ matches.push(`[archive seq ${archive.seq}, message ${index + 1}, ${label}]\n${snippet}`);
764
+ if (matches.length >= Math.max(1, limit))
765
+ break;
766
+ }
767
+ if (matches.length >= Math.max(1, limit))
768
+ break;
769
+ }
770
+ if (!matches.length)
771
+ return `No archived context matches ${JSON.stringify(query)}. ${archives.length} archive file(s) exist for this instance.`;
772
+ return matches.join('\n\n');
773
+ }
774
+ async runTurn(instance) {
775
+ const spec = this.registry.get(instance.agentId);
776
+ if (!spec) {
777
+ instance.status = 'failed';
778
+ instance.lastError = `Agent spec ${instance.agentId} no longer exists`;
779
+ return;
780
+ }
781
+ const session = this.sessions.get(instance.sessionId);
782
+ const controller = new AbortController();
783
+ const turnId = instance.mailbox.filter((message) => message.status === 'pending' && message.turnId).at(-1)?.turnId ?? randomUUID();
784
+ instance.activeTurnId = turnId;
785
+ instance.status = 'running';
786
+ instance.updatedAt = now();
787
+ this.controllers.set(instance.instanceId, controller);
788
+ this.absorbMailbox(instance);
789
+ this.emit({ type: 'instance_updated', instance: cloneInstance(instance) });
790
+ await this.persistSession(instance.sessionId);
791
+ try {
792
+ const config = { ...this.resolveModel(spec.model ?? session.defaultModel ?? this.defaultModel), sessionId: session.sessionId };
793
+ if (!config.model)
794
+ throw new Error('No model configured. Use /provider or /model first.');
795
+ const tools = this.toolsFor(instance, spec);
796
+ let finalOutput = '';
797
+ for (let step = 0; step < 32; step += 1) {
798
+ if (controller.signal.aborted || instance.activeTurnId !== turnId)
799
+ return;
800
+ if (instance.pendingCompact || this.shouldAutoCompact(instance.messages, config)) {
801
+ const reason = instance.pendingCompact?.reason ?? 'auto';
802
+ try {
803
+ await this.compactInstanceMessages(instance, config, {
804
+ focus: instance.pendingCompact?.focus,
805
+ keepRecent: instance.pendingCompact?.keepRecent,
806
+ reason,
807
+ }, controller.signal);
808
+ }
809
+ catch { /* Compaction is best-effort; trimMessages remains the fallback. */ }
810
+ }
811
+ const messages = this.trimMessages(instance.messages, config);
812
+ let text = '';
813
+ let thinking = '';
814
+ const responseItems = [];
815
+ const calls = [];
816
+ for await (const chunk of this.modelStream(config, this.systemPrompt(instance, spec), messages, tools, controller.signal)) {
817
+ if (controller.signal.aborted || instance.activeTurnId !== turnId)
818
+ return;
819
+ if (chunk.responseItems)
820
+ responseItems.push(...chunk.responseItems);
821
+ if (chunk.thinking) {
822
+ thinking += chunk.thinking;
823
+ this.emit({ type: 'thinking_delta', sessionId: session.sessionId, instanceId: instance.instanceId, turnId, text: chunk.thinking });
824
+ }
825
+ if (chunk.content) {
826
+ text += chunk.content;
827
+ finalOutput += chunk.content;
828
+ if (!instance.parentInstanceId) {
829
+ this.emit({ type: 'assistant_delta', sessionId: session.sessionId, instanceId: instance.instanceId, turnId, text: chunk.content });
830
+ }
831
+ }
832
+ if (chunk.toolCalls?.length)
833
+ calls.push(...chunk.toolCalls);
834
+ }
835
+ instance.messages.push({ role: 'assistant', content: text || null, ...(calls.length ? { tool_calls: calls } : {}), ...(responseItems.length ? { responseItems } : {}) });
836
+ if (text.trim() && !instance.parentInstanceId) {
837
+ const visible = { messageId: randomUUID(), role: 'assistant', content: text.trim(), createdAt: now(), turnId, ...(thinking ? { thinking } : {}) };
838
+ session.messages.push(visible);
839
+ session.updatedAt = visible.createdAt;
840
+ this.emit({ type: 'assistant_message', sessionId: session.sessionId, instanceId: instance.instanceId, message: { ...visible } });
841
+ }
842
+ if (!calls.length)
843
+ break;
844
+ for (const call of calls) {
845
+ const args = call.function.arguments;
846
+ const input = JSON.stringify(args);
847
+ this.emit({ type: 'tool_started', instanceId: instance.instanceId, turnId, tool: call.function.name, input });
848
+ const output = await this.executeAgentTool(instance, call.function.name, args, controller.signal);
849
+ if (controller.signal.aborted || instance.activeTurnId !== turnId)
850
+ return;
851
+ instance.messages.push({ role: 'tool', content: output, tool_use_id: call.id });
852
+ this.emit({ type: 'tool_finished', instanceId: instance.instanceId, turnId, tool: call.function.name, output });
853
+ }
854
+ if (instance.pendingCompact && !controller.signal.aborted && instance.activeTurnId === turnId) {
855
+ try {
856
+ await this.compactInstanceMessages(instance, config, {
857
+ focus: instance.pendingCompact.focus,
858
+ keepRecent: instance.pendingCompact.keepRecent,
859
+ reason: instance.pendingCompact.reason,
860
+ }, controller.signal);
861
+ }
862
+ catch { /* Compaction is best-effort. */ }
863
+ }
864
+ if (step === 31)
865
+ throw new Error('Agent reached the 32-step limit. Review the activity and send a follow-up to continue.');
866
+ }
867
+ if (controller.signal.aborted || instance.activeTurnId !== turnId)
868
+ return;
869
+ instance.lastOutput = finalOutput.trim() || instance.lastOutput;
870
+ instance.lastError = undefined;
871
+ instance.status = 'idle';
872
+ instance.activeTurnId = undefined;
873
+ instance.updatedAt = now();
874
+ if (instance.parentInstanceId && instance.lastOutput) {
875
+ const parent = this.instances.get(instance.parentInstanceId);
876
+ if (parent && parent.status !== 'cancelled') {
877
+ this.deliver(parent, `${instance.agentId} (${instance.instanceId.slice(0, 8)}) finished this turn:\n${instance.lastOutput}`, instance.instanceId);
878
+ this.enqueue(parent.instanceId);
879
+ }
880
+ }
881
+ }
882
+ catch (error) {
883
+ if (controller.signal.aborted || instance.activeTurnId !== turnId)
884
+ return;
885
+ instance.status = 'failed';
886
+ instance.lastError = error instanceof Error ? error.message : String(error);
887
+ instance.activeTurnId = undefined;
888
+ instance.updatedAt = now();
889
+ this.emit({ type: 'runtime_error', sessionId: instance.sessionId, instanceId: instance.instanceId, error: instance.lastError });
890
+ if (instance.parentInstanceId) {
891
+ const parent = this.instances.get(instance.parentInstanceId);
892
+ if (parent) {
893
+ this.deliver(parent, `${instance.agentId} failed: ${instance.lastError}`, instance.instanceId);
894
+ this.enqueue(parent.instanceId);
895
+ }
896
+ }
897
+ }
898
+ finally {
899
+ // Interrupted tool batches still need matching results in the next request.
900
+ let lastAssistant = instance.messages.length - 1;
901
+ while (lastAssistant >= 0 && instance.messages[lastAssistant].role !== 'assistant')
902
+ lastAssistant--;
903
+ const unfinished = instance.messages[lastAssistant]?.tool_calls ?? [];
904
+ const answered = new Set(instance.messages.slice(lastAssistant + 1).filter((message) => message.role === 'tool').map((message) => message.tool_use_id));
905
+ for (const call of unfinished) {
906
+ if (!answered.has(call.id))
907
+ instance.messages.push({ role: 'tool', tool_use_id: call.id, content: 'Tool execution interrupted. Check the workspace state before retrying.' });
908
+ }
909
+ if (controller.signal.aborted && instance.activeTurnId === turnId && instance.status !== 'cancelled') {
910
+ instance.activeTurnId = undefined;
911
+ instance.status = instance.mailbox.some((message) => message.status === 'pending') ? 'queued' : 'idle';
912
+ instance.updatedAt = now();
913
+ }
914
+ this.emit({ type: 'instance_updated', instance: cloneInstance(instance) });
915
+ await this.persistSession(instance.sessionId);
916
+ }
917
+ }
918
+ async executeAgentTool(instance, name, args, signal) {
919
+ try {
920
+ if (name === 'spawn_agent') {
921
+ return await this.spawnAgent(instance.instanceId, String(args.agent ?? ''), String(args.message ?? ''));
922
+ }
923
+ if (name === 'send_agent') {
924
+ await this.sendAgent(instance.instanceId, String(args.instance_id ?? ''), String(args.message ?? ''));
925
+ return 'Message delivered.';
926
+ }
927
+ if (name === 'wait_agent') {
928
+ return await this.waitForAgents(instance.instanceId, parseStringList(args.instance_ids), Number(args.timeout_ms ?? 60_000));
929
+ }
930
+ if (name === 'cancel_agent') {
931
+ await this.cancelAgent(instance.instanceId, String(args.instance_id ?? ''));
932
+ return 'Agent cancelled.';
933
+ }
934
+ if (name === 'compact_context') {
935
+ return await this.handleCompactContextTool(instance, args);
936
+ }
937
+ if (name === 'search_history') {
938
+ const targetId = typeof args.instance_id === 'string' && args.instance_id.trim() ? args.instance_id.trim() : instance.instanceId;
939
+ if (!this.isSelfOrDescendant(instance.instanceId, targetId))
940
+ return `Error: agent instance ${targetId} is not you or one of your descendants.`;
941
+ return await this.searchArchivedContext(targetId, String(args.query ?? ''), Number(args.limit ?? 8) || 8);
942
+ }
943
+ const spec = this.registry.get(instance.agentId);
944
+ if (!spec.tools.includes('*') && !spec.tools.includes(name))
945
+ return `Error: tool ${name} is not allowed by agent spec ${spec.id}`;
946
+ return executeTool(name, args, {
947
+ workspaceRoot: this.workspaceRoot,
948
+ taskId: instance.instanceId,
949
+ signal,
950
+ policy: getToolPolicy(),
951
+ acquireWriteLock: (path) => this.fileLocks.acquire(path),
952
+ });
953
+ }
954
+ catch (error) {
955
+ return `Error: ${error instanceof Error ? error.message : String(error)}`;
956
+ }
957
+ }
958
+ async handleCompactContextTool(instance, args) {
959
+ const requestedId = typeof args.instance_id === 'string' && args.instance_id.trim() ? args.instance_id.trim() : instance.instanceId;
960
+ if (!this.isSelfOrDescendant(instance.instanceId, requestedId)) {
961
+ return `Error: agent instance ${requestedId} is not you or one of your descendants.`;
962
+ }
963
+ const focus = typeof args.focus === 'string' ? args.focus : undefined;
964
+ const keepRecent = Number.isFinite(Number(args.keep_recent)) ? Number(args.keep_recent) : undefined;
965
+ if (requestedId === instance.instanceId) {
966
+ // Compacting your own context mid-turn would tear the current tool-call
967
+ // batch apart; apply it at the next safe boundary (end of this batch).
968
+ instance.pendingCompact = { focus, keepRecent, reason: 'manual' };
969
+ return 'Compaction scheduled. Your older messages will be summarized and archived right after this tool batch completes; the digest stays in context and search_history can recall archived details.';
970
+ }
971
+ const target = this.instances.get(requestedId);
972
+ if (target.status === 'running' || target.status === 'waiting') {
973
+ return `Error: agent instance ${requestedId} is still running. Compact it after it becomes idle (wait_agent can help).`;
974
+ }
975
+ const result = await this.compactInstanceMessages(target, this.resolveConfigFor(target), { focus, keepRecent, reason: 'manual' });
976
+ return result.detail;
977
+ }
978
+ async persistSession(sessionId) {
979
+ const session = this.sessions.get(sessionId);
980
+ if (!session)
981
+ return;
982
+ const snapshot = {
983
+ version: 1,
984
+ session: cloneSession(session),
985
+ instances: session.instanceIds.map((id) => this.instances.get(id)).filter((item) => Boolean(item)).map(cloneInstance),
986
+ };
987
+ await this.store.save(snapshot);
988
+ }
989
+ notifyIdleWaiters() {
990
+ for (const waiter of [...this.idleWaiters])
991
+ waiter();
992
+ }
993
+ }