tokenmaw 0.3.0 → 0.4.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/README.md +23 -2
- package/agents/coordinator.md +2 -3
- package/agents/main.md +2 -2
- package/dist/backend.js +31 -1
- package/dist/cli.js +30 -0
- package/dist/infra/tools.js +274 -28
- package/dist/markdown.js +83 -48
- package/dist/responses.js +7 -1
- package/dist/runtime/agent-registry.js +35 -5
- package/dist/runtime/agent-runtime.js +352 -21
- package/dist/runtime/agent-store.js +23 -0
- package/dist/runtime/file-lock.js +256 -0
- package/dist/runtime/locks.js +58 -38
- package/dist/runtime/session-timeline.js +32 -3
- package/dist/runtime/workspace-instances.js +109 -0
- package/dist/runtime/worktree.js +321 -0
- package/dist/ui/bracketed-paste.js +231 -0
- package/dist/ui/commands.js +12 -0
- package/dist/ui/fullscreen-tui.js +1195 -120
- package/dist/ui/markdown.js +19 -9
- package/dist/ui/scrollbar.js +370 -0
- package/dist/ui/syntax.js +3 -5
- package/dist/ui/theme.js +198 -0
- package/dist/ui/tui-design.js +78 -0
- package/dist/ui/welcome.js +144 -11
- package/docs/architecture-revision.md +1 -1
- package/package.json +2 -2
|
@@ -1,14 +1,36 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { chatStream } from '../backend.js';
|
|
4
4
|
import { executeTool, getToolPolicy, toolRegistry } from '../infra/tools.js';
|
|
5
|
-
import { AgentRegistry, matchesAgentSelector } from './agent-registry.js';
|
|
5
|
+
import { AgentRegistry, loadWorkspaceContext, matchesAgentSelector } from './agent-registry.js';
|
|
6
6
|
import { AgentRuntimeStore } from './agent-store.js';
|
|
7
|
+
import { CrossProcessLockManager, LockConflictError } from './file-lock.js';
|
|
7
8
|
import { FileLockManager } from './locks.js';
|
|
8
9
|
import { recordTimeline } from './session-timeline.js';
|
|
10
|
+
const WORKSPACE_CONTEXT_LABEL = 'AGENTS.md';
|
|
9
11
|
function now() {
|
|
10
12
|
return new Date().toISOString();
|
|
11
13
|
}
|
|
14
|
+
function mergeUsage(previous, next) {
|
|
15
|
+
const result = { ...(previous ?? {}) };
|
|
16
|
+
for (const key of ['inputTokens', 'outputTokens', 'reasoningTokens', 'cachedInputTokens', 'cacheCreationInputTokens']) {
|
|
17
|
+
if (next[key] !== undefined)
|
|
18
|
+
result[key] = (result[key] ?? 0) + next[key];
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
function asidePrefix() {
|
|
23
|
+
return 'Additional context noted earlier (aside):';
|
|
24
|
+
}
|
|
25
|
+
function mergeAgentUsage(previous, usage, firstTokenMs, durationMs, requests = 1) {
|
|
26
|
+
const merged = mergeUsage(previous, usage ?? {});
|
|
27
|
+
return { ...merged, requests: (previous?.requests ?? 0) + requests, turns: (previous?.turns ?? 0) + 1, firstTokenMs, lastTurnMs: durationMs };
|
|
28
|
+
}
|
|
29
|
+
function sessionChildren(instances, parentId) {
|
|
30
|
+
return [...instances.values()].filter((instance) => instance.parentInstanceId === parentId);
|
|
31
|
+
}
|
|
32
|
+
/** Tools whose success constitutes real progress (state changed on disk). */
|
|
33
|
+
const PROGRESS_TOOLS = new Set(['edit_file', 'write_file']);
|
|
12
34
|
// ── Context compaction ───────────────────────────────────────────────────────
|
|
13
35
|
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
36
|
const DEFAULT_COMPACT_KEEP_RECENT = 12;
|
|
@@ -103,6 +125,11 @@ export class AgentRuntime {
|
|
|
103
125
|
modelStream;
|
|
104
126
|
maxConcurrentTurns;
|
|
105
127
|
maxAgentDepth;
|
|
128
|
+
maxChildrenPerTurn;
|
|
129
|
+
maxSteps;
|
|
130
|
+
projectContext;
|
|
131
|
+
readVersions = new Map();
|
|
132
|
+
failureCounts = new Map();
|
|
106
133
|
defaultModel;
|
|
107
134
|
sessions = new Map();
|
|
108
135
|
instances = new Map();
|
|
@@ -113,19 +140,35 @@ export class AgentRuntime {
|
|
|
113
140
|
running = new Set();
|
|
114
141
|
controllers = new Map();
|
|
115
142
|
idleWaiters = new Set();
|
|
116
|
-
fileLocks
|
|
143
|
+
fileLocks;
|
|
144
|
+
sessionLocks;
|
|
145
|
+
sessionLockHandles = new Map();
|
|
146
|
+
sessionLockHolders = new Map();
|
|
117
147
|
ready;
|
|
118
148
|
shuttingDown = false;
|
|
119
149
|
constructor(options) {
|
|
120
150
|
this.workspaceRoot = resolve(options.workspaceRoot ?? process.cwd());
|
|
121
151
|
this.registry = options.registry ?? new AgentRegistry({ workspaceRoot: this.workspaceRoot });
|
|
122
152
|
this.store = options.store ?? new AgentRuntimeStore();
|
|
153
|
+
// Locks live next to session state so tests (which pass a tmp store) and
|
|
154
|
+
// alternate CODER_DATA_HOME deployments never touch the default lock dir.
|
|
155
|
+
const lockDir = resolve(this.store.runtimeDir, 'locks');
|
|
156
|
+
this.fileLocks = new FileLockManager(lockDir);
|
|
157
|
+
this.sessionLocks = new CrossProcessLockManager(lockDir);
|
|
123
158
|
this.resolveModel = options.resolveModel;
|
|
124
159
|
this.defaultModel = options.defaultModel;
|
|
125
160
|
this.modelStream = options.modelStream ?? ((config, system, messages, tools, signal) => (chatStream(config, system, messages, tools, signal)));
|
|
126
161
|
this.maxConcurrentTurns = Math.max(1, options.maxConcurrentTurns ?? Number(process.env.AGENT_MAX_CONCURRENT_TURNS ?? 4));
|
|
127
162
|
this.maxAgentDepth = Math.max(1, options.maxAgentDepth ?? Number(process.env.AGENT_MAX_DEPTH ?? 4));
|
|
128
|
-
this.
|
|
163
|
+
this.maxChildrenPerTurn = Math.max(1, options.maxChildrenPerTurn ?? Number(process.env.AGENT_MAX_CHILDREN_PER_TURN ?? 3));
|
|
164
|
+
this.maxSteps = options.maxSteps;
|
|
165
|
+
const contextPromise = options.projectContext !== undefined
|
|
166
|
+
? Promise.resolve(options.projectContext)
|
|
167
|
+
: loadWorkspaceContext(this.workspaceRoot);
|
|
168
|
+
this.ready = Promise.all([this.registry.load(), this.store.init(), contextPromise]).then(([, , context]) => {
|
|
169
|
+
this.projectContext = context;
|
|
170
|
+
this.validateSpecs();
|
|
171
|
+
});
|
|
129
172
|
}
|
|
130
173
|
whenReady() {
|
|
131
174
|
return this.ready;
|
|
@@ -164,11 +207,123 @@ export class AgentRuntime {
|
|
|
164
207
|
listAgentSpecs() {
|
|
165
208
|
return this.registry.list();
|
|
166
209
|
}
|
|
210
|
+
/** Absolute workspace root tools resolve relative paths against (/cd target). */
|
|
211
|
+
workspace() {
|
|
212
|
+
return this.workspaceRoot;
|
|
213
|
+
}
|
|
214
|
+
/** Switch the workspace for this runtime (/cd). Resolves `path` against the
|
|
215
|
+
* current root, revalidates it, reloads project agent specs and AGENTS.md,
|
|
216
|
+
* and re-reads project context so every following turn runs in the new root.
|
|
217
|
+
* Ongoing turns keep their already-composed prompts; the change lands on the
|
|
218
|
+
* next turn. */
|
|
219
|
+
async changeWorkspace(path, options = {}) {
|
|
220
|
+
await this.ready;
|
|
221
|
+
const previousRoot = this.workspaceRoot;
|
|
222
|
+
const target = resolve(previousRoot, path.trim());
|
|
223
|
+
let stat;
|
|
224
|
+
try {
|
|
225
|
+
stat = await import('node:fs/promises').then((fs) => fs.stat(target));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
throw new Error(`cd: no such directory: ${path}`);
|
|
229
|
+
}
|
|
230
|
+
if (!stat.isDirectory())
|
|
231
|
+
throw new Error(`cd: not a directory: ${target}`);
|
|
232
|
+
if (target === previousRoot)
|
|
233
|
+
return { from: previousRoot, to: target };
|
|
234
|
+
this.workspaceRoot = target;
|
|
235
|
+
this.registry.setProjectDir(target);
|
|
236
|
+
this.projectContext = await loadWorkspaceContext(target);
|
|
237
|
+
this.readVersions.clear();
|
|
238
|
+
this.failureCounts.clear();
|
|
239
|
+
try {
|
|
240
|
+
await this.registry.load();
|
|
241
|
+
this.validateSpecs();
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
// Roll back so the runtime never stays half-switched between roots.
|
|
245
|
+
this.workspaceRoot = previousRoot;
|
|
246
|
+
this.registry.setProjectDir(previousRoot);
|
|
247
|
+
this.projectContext = await loadWorkspaceContext(previousRoot).catch(() => undefined);
|
|
248
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
249
|
+
}
|
|
250
|
+
this.emit({ type: 'workspace_changed', sessionId: options.sessionId, workspaceRoot: target, previousRoot });
|
|
251
|
+
return { from: previousRoot, to: target };
|
|
252
|
+
}
|
|
253
|
+
// ── Cross-process session access (single writer, many read-only viewers) ───
|
|
254
|
+
sessionLockTarget(sessionId) {
|
|
255
|
+
return this.store.sessionPath(sessionId);
|
|
256
|
+
}
|
|
257
|
+
sessionLockTimeout() {
|
|
258
|
+
return Math.max(250, Number(process.env.AGENT_SESSION_LOCK_TIMEOUT_MS ?? 5_000));
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Try to become the session's cross-process writer. Returns `writable: false`
|
|
262
|
+
* (instead of throwing) when another process owns the session; the caller
|
|
263
|
+
* opens the session in read-only mode.
|
|
264
|
+
*/
|
|
265
|
+
async ensureSessionLock(sessionId) {
|
|
266
|
+
if (this.sessionLockHandles.has(sessionId))
|
|
267
|
+
return { writable: true };
|
|
268
|
+
try {
|
|
269
|
+
const handle = await this.sessionLocks.acquire(this.sessionLockTarget(sessionId), {
|
|
270
|
+
timeoutMs: this.sessionLockTimeout(),
|
|
271
|
+
purpose: `session:${sessionId}`,
|
|
272
|
+
session: sessionId,
|
|
273
|
+
});
|
|
274
|
+
this.sessionLockHandles.set(sessionId, handle);
|
|
275
|
+
this.sessionLockHolders.delete(sessionId);
|
|
276
|
+
return { writable: true };
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
if (!(error instanceof LockConflictError))
|
|
280
|
+
throw error;
|
|
281
|
+
const holder = error.holder ?? await this.sessionLocks.holder(this.sessionLockTarget(sessionId));
|
|
282
|
+
if (holder)
|
|
283
|
+
this.sessionLockHolders.set(sessionId, holder);
|
|
284
|
+
return { writable: false, holder };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async releaseSessionLock(sessionId) {
|
|
288
|
+
const handle = this.sessionLockHandles.get(sessionId);
|
|
289
|
+
if (!handle)
|
|
290
|
+
return;
|
|
291
|
+
this.sessionLockHandles.delete(sessionId);
|
|
292
|
+
this.sessionLockHolders.delete(sessionId);
|
|
293
|
+
await handle.release();
|
|
294
|
+
}
|
|
295
|
+
/** Cross-process access state for UI status display. */
|
|
296
|
+
sessionAccess(sessionId) {
|
|
297
|
+
if (this.sessionLockHandles.has(sessionId))
|
|
298
|
+
return { writable: true };
|
|
299
|
+
if (!this.sessions.has(sessionId))
|
|
300
|
+
return { writable: true }; // never opened here; no restriction known
|
|
301
|
+
const holder = this.sessionLockHolders.get(sessionId);
|
|
302
|
+
if (!holder)
|
|
303
|
+
return { writable: false };
|
|
304
|
+
return { writable: false, holderPid: holder.pid, holderSession: holder.session };
|
|
305
|
+
}
|
|
306
|
+
/** Gate for mutating entry points. Opens the session, then self-heals a
|
|
307
|
+
* read-only state when the previous writer has since exited; otherwise
|
|
308
|
+
* fails with guidance (review or /fork). */
|
|
309
|
+
async requireSessionWrite(sessionId) {
|
|
310
|
+
await this.openSession(sessionId);
|
|
311
|
+
if (this.sessionLockHandles.has(sessionId))
|
|
312
|
+
return;
|
|
313
|
+
const access = await this.ensureSessionLock(sessionId);
|
|
314
|
+
if (access.writable)
|
|
315
|
+
return;
|
|
316
|
+
const holder = access.holder ?? this.sessionLockHolders.get(sessionId);
|
|
317
|
+
const who = holder ? `pid ${holder.pid}${holder.session ? ` (session ${holder.session})` : ''}` : 'another process';
|
|
318
|
+
throw new Error(`Session is read-only: it is currently being written by ${who}. ` +
|
|
319
|
+
'You can keep reviewing the conversation; to continue working from this point, run /fork to get your own writable copy.');
|
|
320
|
+
}
|
|
167
321
|
async openSession(sessionId = `session-${Date.now()}`) {
|
|
168
322
|
await this.ready;
|
|
169
323
|
const current = this.sessions.get(sessionId);
|
|
170
324
|
if (current)
|
|
171
325
|
return cloneSession(current);
|
|
326
|
+
const access = await this.ensureSessionLock(sessionId);
|
|
172
327
|
const persisted = await this.store.load(sessionId);
|
|
173
328
|
if (persisted) {
|
|
174
329
|
const session = persisted.session;
|
|
@@ -180,11 +335,20 @@ export class AgentRuntime {
|
|
|
180
335
|
}
|
|
181
336
|
this.instances.set(instance.instanceId, instance);
|
|
182
337
|
}
|
|
183
|
-
|
|
184
|
-
|
|
338
|
+
// Read-only viewers never schedule recovered turns: the writer process
|
|
339
|
+
// that crashed owned that queue, and re-running it from here would race.
|
|
340
|
+
if (access.writable) {
|
|
341
|
+
for (const instance of persisted.instances.filter((item) => item.status === 'queued'))
|
|
342
|
+
this.enqueue(instance.instanceId);
|
|
343
|
+
}
|
|
185
344
|
this.emit({ type: 'session_opened', session: cloneSession(session) });
|
|
186
345
|
return cloneSession(session);
|
|
187
346
|
}
|
|
347
|
+
if (!access.writable) {
|
|
348
|
+
const holder = access.holder;
|
|
349
|
+
const who = holder ? `pid ${holder.pid}${holder.session ? ` (session ${holder.session})` : ''}` : 'another process';
|
|
350
|
+
throw new Error(`Session ${sessionId} is currently being written by ${who}. Pick a new session id, or wait for the other process to exit.`);
|
|
351
|
+
}
|
|
188
352
|
if (!this.registry.get('main'))
|
|
189
353
|
throw new Error('No main agent spec found');
|
|
190
354
|
const createdAt = now();
|
|
@@ -231,15 +395,37 @@ export class AgentRuntime {
|
|
|
231
395
|
this.queued.delete(id);
|
|
232
396
|
}
|
|
233
397
|
this.sessions.delete(sessionId);
|
|
234
|
-
await this.
|
|
398
|
+
await this.releaseSessionLock(sessionId);
|
|
399
|
+
// Deleting is itself a cross-process mutation: refuse when another
|
|
400
|
+
// process currently owns the session.
|
|
401
|
+
try {
|
|
402
|
+
const handle = await this.sessionLocks.acquire(this.sessionLockTarget(sessionId), {
|
|
403
|
+
timeoutMs: 2_000,
|
|
404
|
+
purpose: `session:${sessionId}:delete`,
|
|
405
|
+
session: sessionId,
|
|
406
|
+
});
|
|
407
|
+
try {
|
|
408
|
+
await this.store.remove(sessionId);
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
await handle.release();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
if (error instanceof LockConflictError) {
|
|
416
|
+
throw new Error(`Session ${sessionId} cannot be removed while another process is using it (pid ${error.holder?.pid ?? 'unknown'}).`);
|
|
417
|
+
}
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
235
420
|
}
|
|
236
421
|
async clearSession(sessionId) {
|
|
237
|
-
|
|
238
|
-
await this.openSession(sessionId);
|
|
422
|
+
await this.requireSessionWrite(sessionId);
|
|
239
423
|
await this.cancelSession(sessionId);
|
|
240
424
|
const session = this.sessions.get(sessionId);
|
|
241
425
|
session.messages = [];
|
|
242
426
|
session.timeline = [];
|
|
427
|
+
session.pendingAsides = [];
|
|
428
|
+
session.goal = undefined;
|
|
243
429
|
session.updatedAt = now();
|
|
244
430
|
const main = this.instances.get(session.mainInstanceId);
|
|
245
431
|
if (main) {
|
|
@@ -255,6 +441,8 @@ export class AgentRuntime {
|
|
|
255
441
|
await this.persistSession(sessionId);
|
|
256
442
|
}
|
|
257
443
|
async cancelSession(sessionId) {
|
|
444
|
+
if (!this.sessionLockHandles.has(sessionId))
|
|
445
|
+
await this.requireSessionWrite(sessionId);
|
|
258
446
|
const session = this.sessions.get(sessionId);
|
|
259
447
|
if (!session)
|
|
260
448
|
return;
|
|
@@ -273,15 +461,81 @@ export class AgentRuntime {
|
|
|
273
461
|
await this.persistSession(sessionId);
|
|
274
462
|
this.notifyIdleWaiters();
|
|
275
463
|
}
|
|
464
|
+
/** Queue an aside (/aside): folds into the next submitted message without starting a turn. */
|
|
465
|
+
async addAside(sessionId, content) {
|
|
466
|
+
const text = content.trim();
|
|
467
|
+
if (!text)
|
|
468
|
+
throw new Error('Aside cannot be empty');
|
|
469
|
+
await this.requireSessionWrite(sessionId);
|
|
470
|
+
const session = this.sessions.get(sessionId);
|
|
471
|
+
const hadAsides = (session.pendingAsides?.length ?? 0) > 0;
|
|
472
|
+
(session.pendingAsides ??= []).push(text);
|
|
473
|
+
session.updatedAt = now();
|
|
474
|
+
await this.persistSession(sessionId);
|
|
475
|
+
this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content: hadAsides
|
|
476
|
+
? `Noted — another aside is already queued; both will be included with your next message.`
|
|
477
|
+
: `Noted. This will be included with your next message without starting a turn.`, createdAt: now() } });
|
|
478
|
+
return { queued: true, detail: hadAsides
|
|
479
|
+
? 'Queued behind one earlier aside; both will be included with the next message.'
|
|
480
|
+
: 'Queued. It will be included with the next message without starting a turn.' };
|
|
481
|
+
}
|
|
482
|
+
/** Set or clear the standing session goal (/goal). Injected into every agent's prompt until cleared. */
|
|
483
|
+
async setSessionGoal(sessionId, goal) {
|
|
484
|
+
const text = goal.trim();
|
|
485
|
+
await this.requireSessionWrite(sessionId);
|
|
486
|
+
const session = this.sessions.get(sessionId);
|
|
487
|
+
session.goal = text || undefined;
|
|
488
|
+
session.updatedAt = now();
|
|
489
|
+
await this.persistSession(sessionId);
|
|
490
|
+
this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system',
|
|
491
|
+
content: text ? `Session goal set: ${text}` : 'Session goal cleared.', createdAt: now() } });
|
|
492
|
+
return { set: Boolean(text), detail: text ? `Goal set. It now applies to every agent in this session: ${text}` : 'Goal cleared.' };
|
|
493
|
+
}
|
|
494
|
+
/** Emit a session-scoped timeline notice that never reaches the model. */
|
|
495
|
+
async emitSystemNotice(sessionId, content) {
|
|
496
|
+
if (!this.sessions.has(sessionId))
|
|
497
|
+
await this.openSession(sessionId);
|
|
498
|
+
this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content, createdAt: now() } });
|
|
499
|
+
}
|
|
500
|
+
/** Fork a session into a new persisted copy (used by /fork and /btw side conversations).
|
|
501
|
+
* Works from a read-only session: the copy is taken from the persisted file
|
|
502
|
+
* and the fork's lock is held while copying so a concurrent opener of the
|
|
503
|
+
* same new id either waits or loses the race cleanly. */
|
|
504
|
+
async forkSession(sessionId, newSessionId = `session-${Date.now()}`) {
|
|
505
|
+
await this.openSession(sessionId);
|
|
506
|
+
await this.persistSession(sessionId);
|
|
507
|
+
const handle = await this.sessionLocks.acquire(this.sessionLockTarget(newSessionId), {
|
|
508
|
+
timeoutMs: this.sessionLockTimeout(),
|
|
509
|
+
purpose: `session:${newSessionId}:fork`,
|
|
510
|
+
session: newSessionId,
|
|
511
|
+
});
|
|
512
|
+
try {
|
|
513
|
+
await this.store.copySession(sessionId, newSessionId);
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
await handle.release();
|
|
517
|
+
}
|
|
518
|
+
const detail = `Forked into ${newSessionId}. /sessions lists both; the original stays untouched.`;
|
|
519
|
+
this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content: detail, createdAt: now() } });
|
|
520
|
+
return { sessionId: newSessionId, detail };
|
|
521
|
+
}
|
|
276
522
|
async submitMessage(sessionId, content) {
|
|
277
523
|
const text = content.trim();
|
|
278
524
|
if (!text)
|
|
279
525
|
throw new Error('Message cannot be empty');
|
|
280
|
-
await this.
|
|
526
|
+
await this.requireSessionWrite(sessionId);
|
|
281
527
|
const session = this.sessions.get(sessionId);
|
|
282
528
|
const main = this.instances.get(session.mainInstanceId);
|
|
283
529
|
const turnId = randomUUID();
|
|
284
|
-
const
|
|
530
|
+
const queuedAsides = session.pendingAsides ?? [];
|
|
531
|
+
session.pendingAsides = [];
|
|
532
|
+
const composed = queuedAsides.length
|
|
533
|
+
? `${text}
|
|
534
|
+
|
|
535
|
+
${asidePrefix()}
|
|
536
|
+
${queuedAsides.map((aside, index) => `${index + 1}. ${aside}`).join('\n')}`
|
|
537
|
+
: text;
|
|
538
|
+
const message = { messageId: randomUUID(), role: 'user', content: composed, createdAt: now(), turnId };
|
|
285
539
|
session.messages.push(message);
|
|
286
540
|
session.updatedAt = message.createdAt;
|
|
287
541
|
if (main.status === 'running' || main.status === 'waiting') {
|
|
@@ -289,9 +543,20 @@ export class AgentRuntime {
|
|
|
289
543
|
}
|
|
290
544
|
if (main.status === 'cancelled')
|
|
291
545
|
main.status = 'idle';
|
|
292
|
-
|
|
546
|
+
// The model must see the folded asides, so deliver the composed message.
|
|
547
|
+
// The TUI renders the asides as separate system entries from the emitted
|
|
548
|
+
// system_message events above, while this user message keeps them inline.
|
|
549
|
+
this.deliver(main, composed, undefined, turnId);
|
|
293
550
|
await this.persistSession(sessionId);
|
|
294
551
|
this.emit({ type: 'user_message', sessionId, message: { ...message } });
|
|
552
|
+
if (queuedAsides.length) {
|
|
553
|
+
// Timeline-only notices; the user message itself already carries the
|
|
554
|
+
// asides inline for the model.
|
|
555
|
+
this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content: `Aside${queuedAsides.length > 1 ? 's' : ''} included with your message:`, createdAt: now() } });
|
|
556
|
+
for (const aside of queuedAsides) {
|
|
557
|
+
this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content: `· ${aside}`, createdAt: now() } });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
295
560
|
this.enqueue(main.instanceId);
|
|
296
561
|
return turnId;
|
|
297
562
|
}
|
|
@@ -309,8 +574,13 @@ export class AgentRuntime {
|
|
|
309
574
|
const ancestors = this.ancestorAgentIds(parent);
|
|
310
575
|
if (ancestors.has(agentId))
|
|
311
576
|
throw new Error(`Agent call cycle rejected: ${agentId} already exists in the ancestor chain`);
|
|
577
|
+
const turnId = parent.activeTurnId;
|
|
578
|
+
const childrenThisTurn = sessionChildren(this.instances, parent.instanceId).filter((child) => child.parentTurnId === turnId).length;
|
|
579
|
+
if (childrenThisTurn >= this.maxChildrenPerTurn)
|
|
580
|
+
throw new Error(`Maximum of ${this.maxChildrenPerTurn} child agents per turn reached; reuse an existing agent or continue directly.`);
|
|
312
581
|
const session = this.sessions.get(parent.sessionId);
|
|
313
582
|
const child = this.newInstance(parent.sessionId, agentId, parent.instanceId, parent.depth + 1);
|
|
583
|
+
child.parentTurnId = turnId;
|
|
314
584
|
this.instances.set(child.instanceId, child);
|
|
315
585
|
parent.childInstanceIds.push(child.instanceId);
|
|
316
586
|
parent.updatedAt = now();
|
|
@@ -467,6 +737,9 @@ export class AgentRuntime {
|
|
|
467
737
|
for (const sessionId of this.sessions.keys())
|
|
468
738
|
await this.persistSession(sessionId);
|
|
469
739
|
await this.store.flush();
|
|
740
|
+
for (const sessionId of [...this.sessionLockHandles.keys()]) {
|
|
741
|
+
await this.releaseSessionLock(sessionId).catch(() => undefined);
|
|
742
|
+
}
|
|
470
743
|
}
|
|
471
744
|
newInstance(sessionId, agentId, parentInstanceId, depth = 0, createdAt = now()) {
|
|
472
745
|
if (!this.registry.get(agentId))
|
|
@@ -567,11 +840,15 @@ export class AgentRuntime {
|
|
|
567
840
|
}
|
|
568
841
|
systemPrompt(instance, spec) {
|
|
569
842
|
const catalog = this.registry.allowedAgents(spec);
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
843
|
+
// Keep the system prefix stable between model calls. Injecting every sibling's
|
|
844
|
+
// live status here invalidates provider prompt caches and burns input tokens;
|
|
845
|
+
// child results are delivered through the mailbox and remain visible in the
|
|
846
|
+
// normal conversation context.
|
|
847
|
+
const session = this.sessions.get(instance.sessionId);
|
|
573
848
|
return [
|
|
574
849
|
spec.instructions,
|
|
850
|
+
...(session?.goal ? ['', `Standing goal for this session (highest priority; stay aligned with it unless the user says otherwise):`, session.goal] : []),
|
|
851
|
+
...(this.projectContext ? ['', `Project context (${WORKSPACE_CONTEXT_LABEL}):`, this.projectContext] : []),
|
|
575
852
|
'',
|
|
576
853
|
'Runtime contract:',
|
|
577
854
|
`- You are agent "${spec.id}" in workspace ${this.workspaceRoot}.`,
|
|
@@ -584,9 +861,7 @@ export class AgentRuntime {
|
|
|
584
861
|
catalog.length
|
|
585
862
|
? `Available agents:\n${catalog.map((agent) => `- ${agent.id}: ${agent.description}`).join('\n')}`
|
|
586
863
|
: 'Available agents: none.',
|
|
587
|
-
|
|
588
|
-
? `Existing instances in this session:\n${relatedInstances.join('\n')}`
|
|
589
|
-
: 'Existing instances in this session: none.',
|
|
864
|
+
'Existing instances are communicated through mailbox messages. Reuse an existing related agent when possible; do not spawn duplicates.',
|
|
590
865
|
].join('\n');
|
|
591
866
|
}
|
|
592
867
|
toolsFor(instance, spec) {
|
|
@@ -784,6 +1059,7 @@ export class AgentRuntime {
|
|
|
784
1059
|
instance.activeTurnId = turnId;
|
|
785
1060
|
instance.status = 'running';
|
|
786
1061
|
instance.updatedAt = now();
|
|
1062
|
+
this.failureCounts.delete(instance.instanceId);
|
|
787
1063
|
this.controllers.set(instance.instanceId, controller);
|
|
788
1064
|
this.absorbMailbox(instance);
|
|
789
1065
|
this.emit({ type: 'instance_updated', instance: cloneInstance(instance) });
|
|
@@ -794,7 +1070,12 @@ export class AgentRuntime {
|
|
|
794
1070
|
throw new Error('No model configured. Use /provider or /model first.');
|
|
795
1071
|
const tools = this.toolsFor(instance, spec);
|
|
796
1072
|
let finalOutput = '';
|
|
797
|
-
|
|
1073
|
+
const turnStartedAt = Date.now();
|
|
1074
|
+
let turnUsage;
|
|
1075
|
+
let firstTokenMs;
|
|
1076
|
+
let requestCount = 0;
|
|
1077
|
+
const stepLimit = Math.max(1, this.maxSteps ?? (instance.parentInstanceId ? 48 : 64));
|
|
1078
|
+
for (let step = 0; step < stepLimit; step += 1) {
|
|
798
1079
|
if (controller.signal.aborted || instance.activeTurnId !== turnId)
|
|
799
1080
|
return;
|
|
800
1081
|
if (instance.pendingCompact || this.shouldAutoCompact(instance.messages, config)) {
|
|
@@ -809,6 +1090,7 @@ export class AgentRuntime {
|
|
|
809
1090
|
catch { /* Compaction is best-effort; trimMessages remains the fallback. */ }
|
|
810
1091
|
}
|
|
811
1092
|
const messages = this.trimMessages(instance.messages, config);
|
|
1093
|
+
requestCount += 1;
|
|
812
1094
|
let text = '';
|
|
813
1095
|
let thinking = '';
|
|
814
1096
|
const responseItems = [];
|
|
@@ -823,12 +1105,16 @@ export class AgentRuntime {
|
|
|
823
1105
|
this.emit({ type: 'thinking_delta', sessionId: session.sessionId, instanceId: instance.instanceId, turnId, text: chunk.thinking });
|
|
824
1106
|
}
|
|
825
1107
|
if (chunk.content) {
|
|
1108
|
+
if (firstTokenMs === undefined)
|
|
1109
|
+
firstTokenMs = Date.now() - turnStartedAt;
|
|
826
1110
|
text += chunk.content;
|
|
827
1111
|
finalOutput += chunk.content;
|
|
828
1112
|
if (!instance.parentInstanceId) {
|
|
829
1113
|
this.emit({ type: 'assistant_delta', sessionId: session.sessionId, instanceId: instance.instanceId, turnId, text: chunk.content });
|
|
830
1114
|
}
|
|
831
1115
|
}
|
|
1116
|
+
if (chunk.usage)
|
|
1117
|
+
turnUsage = mergeUsage(turnUsage, chunk.usage);
|
|
832
1118
|
if (chunk.toolCalls?.length)
|
|
833
1119
|
calls.push(...chunk.toolCalls);
|
|
834
1120
|
}
|
|
@@ -850,6 +1136,31 @@ export class AgentRuntime {
|
|
|
850
1136
|
return;
|
|
851
1137
|
instance.messages.push({ role: 'tool', content: output, tool_use_id: call.id });
|
|
852
1138
|
this.emit({ type: 'tool_finished', instanceId: instance.instanceId, turnId, tool: call.function.name, output });
|
|
1139
|
+
const fingerprint = createHash('sha256')
|
|
1140
|
+
.update(call.function.name)
|
|
1141
|
+
.update('\0')
|
|
1142
|
+
.update(JSON.stringify(args))
|
|
1143
|
+
.update('\0')
|
|
1144
|
+
.update(output)
|
|
1145
|
+
.digest('hex');
|
|
1146
|
+
const failed = /^Error:/i.test(output) || /PolicyError/.test(output);
|
|
1147
|
+
if (failed) {
|
|
1148
|
+
// Progress, not recency, resets the failure chain: counts are kept
|
|
1149
|
+
// per fingerprint, so interleaved successful reads (which prove
|
|
1150
|
+
// nothing changed) cannot launder a repeating failure.
|
|
1151
|
+
const counts = this.failureCounts.get(instance.instanceId) ?? new Map();
|
|
1152
|
+
const repeats = (counts.get(fingerprint) ?? 0) + 1;
|
|
1153
|
+
counts.set(fingerprint, repeats);
|
|
1154
|
+
this.failureCounts.set(instance.instanceId, counts);
|
|
1155
|
+
if (repeats >= 3) {
|
|
1156
|
+
throw new Error(`Doom loop detected: ${call.function.name} produced the same failure ${repeats} times without progress. Change approach or inspect the diagnostic before retrying.`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
else if (PROGRESS_TOOLS.has(call.function.name)) {
|
|
1160
|
+
// Only a state-changing success (an actual write) counts as
|
|
1161
|
+
// progress; read-only successes leave the failure chain intact.
|
|
1162
|
+
this.failureCounts.delete(instance.instanceId);
|
|
1163
|
+
}
|
|
853
1164
|
}
|
|
854
1165
|
if (instance.pendingCompact && !controller.signal.aborted && instance.activeTurnId === turnId) {
|
|
855
1166
|
try {
|
|
@@ -861,12 +1172,15 @@ export class AgentRuntime {
|
|
|
861
1172
|
}
|
|
862
1173
|
catch { /* Compaction is best-effort. */ }
|
|
863
1174
|
}
|
|
864
|
-
if (step ===
|
|
865
|
-
throw new Error(
|
|
1175
|
+
if (step === stepLimit - 1)
|
|
1176
|
+
throw new Error(`Agent reached the ${stepLimit}-step safety limit. Review the activity and send a follow-up to continue.`);
|
|
866
1177
|
}
|
|
867
1178
|
if (controller.signal.aborted || instance.activeTurnId !== turnId)
|
|
868
1179
|
return;
|
|
869
1180
|
instance.lastOutput = finalOutput.trim() || instance.lastOutput;
|
|
1181
|
+
const endedAt = now();
|
|
1182
|
+
instance.usage = mergeAgentUsage(instance.usage, turnUsage, firstTokenMs, Date.now() - turnStartedAt, requestCount);
|
|
1183
|
+
instance.lastTurn = { startedAt: new Date(turnStartedAt).toISOString(), endedAt, durationMs: Date.now() - turnStartedAt, usage: turnUsage };
|
|
870
1184
|
instance.lastError = undefined;
|
|
871
1185
|
instance.status = 'idle';
|
|
872
1186
|
instance.activeTurnId = undefined;
|
|
@@ -949,6 +1263,19 @@ export class AgentRuntime {
|
|
|
949
1263
|
signal,
|
|
950
1264
|
policy: getToolPolicy(),
|
|
951
1265
|
acquireWriteLock: (path) => this.fileLocks.acquire(path),
|
|
1266
|
+
requirePriorRead: true,
|
|
1267
|
+
getReadVersion: (path) => this.readVersions.get(instance.instanceId)?.get(resolve(path)),
|
|
1268
|
+
recordReadVersion: (path, version) => {
|
|
1269
|
+
let versions = this.readVersions.get(instance.instanceId);
|
|
1270
|
+
if (!versions) {
|
|
1271
|
+
versions = new Map();
|
|
1272
|
+
this.readVersions.set(instance.instanceId, versions);
|
|
1273
|
+
}
|
|
1274
|
+
versions.set(resolve(path), version);
|
|
1275
|
+
},
|
|
1276
|
+
recordWriteVersion: (path, _version) => {
|
|
1277
|
+
this.readVersions.get(instance.instanceId)?.delete(resolve(path));
|
|
1278
|
+
},
|
|
952
1279
|
});
|
|
953
1280
|
}
|
|
954
1281
|
catch (error) {
|
|
@@ -979,6 +1306,10 @@ export class AgentRuntime {
|
|
|
979
1306
|
const session = this.sessions.get(sessionId);
|
|
980
1307
|
if (!session)
|
|
981
1308
|
return;
|
|
1309
|
+
// Read-only viewers never persist: the session file belongs to the
|
|
1310
|
+
// cross-process writer holding the session lock.
|
|
1311
|
+
if (!this.sessionLockHandles.has(sessionId))
|
|
1312
|
+
return;
|
|
982
1313
|
const snapshot = {
|
|
983
1314
|
version: 1,
|
|
984
1315
|
session: cloneSession(session),
|
|
@@ -31,6 +31,10 @@ export class AgentRuntimeStore {
|
|
|
31
31
|
constructor(baseDir = process.env.CODER_DATA_HOME?.trim() || resolve(homedir(), '.coder')) {
|
|
32
32
|
this.dir = resolve(baseDir, 'runtime');
|
|
33
33
|
}
|
|
34
|
+
/** Root directory for runtime state (sessions, archives, locks, instances). */
|
|
35
|
+
get runtimeDir() {
|
|
36
|
+
return this.dir;
|
|
37
|
+
}
|
|
34
38
|
async init() {
|
|
35
39
|
await mkdir(this.dir, { recursive: true });
|
|
36
40
|
}
|
|
@@ -38,6 +42,10 @@ export class AgentRuntimeStore {
|
|
|
38
42
|
validSessionId(sessionId);
|
|
39
43
|
return resolve(this.dir, `${sessionId}.json`);
|
|
40
44
|
}
|
|
45
|
+
/** Absolute path of the persisted session snapshot (cross-process lock target). */
|
|
46
|
+
sessionPath(sessionId) {
|
|
47
|
+
return this.path(sessionId);
|
|
48
|
+
}
|
|
41
49
|
async save(snapshot) {
|
|
42
50
|
const path = this.path(snapshot.session.sessionId);
|
|
43
51
|
const payload = `${JSON.stringify(snapshot, null, 2)}\n`;
|
|
@@ -75,6 +83,21 @@ export class AgentRuntimeStore {
|
|
|
75
83
|
return undefined;
|
|
76
84
|
}
|
|
77
85
|
}
|
|
86
|
+
/** Duplicate one persisted session file under a new session id (used by /fork). */
|
|
87
|
+
async copySession(sourceSessionId, newSessionId) {
|
|
88
|
+
const source = await this.load(sourceSessionId);
|
|
89
|
+
if (!source)
|
|
90
|
+
throw new Error(`Session ${sourceSessionId} is not persisted yet.`);
|
|
91
|
+
const snapshot = {
|
|
92
|
+
version: 1,
|
|
93
|
+
session: { ...source.session, sessionId: newSessionId },
|
|
94
|
+
instances: source.instances.map((instance) => ({ ...instance, sessionId: newSessionId })),
|
|
95
|
+
};
|
|
96
|
+
await this.save(snapshot);
|
|
97
|
+
for (const archive of await this.loadArchives(sourceSessionId)) {
|
|
98
|
+
await this.saveArchive(newSessionId, archive.instanceId, archive.seq, archive.messages);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
78
101
|
async list() {
|
|
79
102
|
await Promise.all([...writes.values()].map((write) => write.catch(() => undefined)));
|
|
80
103
|
let files = [];
|