tokenmaw 0.3.0 → 0.4.1
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 +22 -2
- package/agents/coordinator.md +2 -3
- package/agents/main.md +2 -2
- package/dist/backend.js +31 -1
- package/dist/cli.js +46 -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 +309 -19
- package/dist/runtime/agent-store.js +52 -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 +11 -0
- package/dist/ui/fullscreen-tui.js +1282 -122
- 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 +555 -11
- package/dist/update-check.js +332 -0
- package/docs/architecture-revision.md +1 -1
- package/package.json +9 -3
package/dist/responses.js
CHANGED
|
@@ -66,7 +66,13 @@ export async function* responsesStream(config, instructions, messages, tools, si
|
|
|
66
66
|
throw new Error(event.response?.error?.message ?? event.message ?? event.response?.incomplete_details?.reason ?? `Responses: ${event.type}`);
|
|
67
67
|
}
|
|
68
68
|
if (event.type === 'response.completed') {
|
|
69
|
-
|
|
69
|
+
const usage = event.response?.usage;
|
|
70
|
+
yield { content: null, done: true, usage: usage ? {
|
|
71
|
+
inputTokens: usage.input_tokens,
|
|
72
|
+
outputTokens: usage.output_tokens,
|
|
73
|
+
cachedInputTokens: usage.input_tokens_details?.cached_tokens,
|
|
74
|
+
reasoningTokens: usage.output_tokens_details?.reasoning_tokens,
|
|
75
|
+
} : undefined };
|
|
70
76
|
return;
|
|
71
77
|
}
|
|
72
78
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
4
4
|
function unquote(value) {
|
|
5
5
|
const trimmed = value.trim();
|
|
6
6
|
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
@@ -86,6 +86,25 @@ async function markdownFiles(root) {
|
|
|
86
86
|
function specId(root, file) {
|
|
87
87
|
return relative(root, file).split(sep).join('/').replace(/\.md$/i, '');
|
|
88
88
|
}
|
|
89
|
+
/** Names checked in order when loading project context. The lowercase form
|
|
90
|
+
* covers case-insensitive filesystems; the capitalized forms follow the
|
|
91
|
+
* agents.md convention used by other coding agents. */
|
|
92
|
+
const WORKSPACE_CONTEXT_FILENAMES = ['AGENTS.md', 'AGENT.md', 'agents.md'];
|
|
93
|
+
/** Loads an optional AGENTS.md-style project context document from the
|
|
94
|
+
* workspace root. Plain Markdown with no frontmatter; a missing, empty, or
|
|
95
|
+
* unreadable file is not an error — the convention is opt-in per workspace. */
|
|
96
|
+
export async function loadWorkspaceContext(root) {
|
|
97
|
+
for (const name of WORKSPACE_CONTEXT_FILENAMES) {
|
|
98
|
+
try {
|
|
99
|
+
const content = (await readFile(join(root, name), 'utf8')).replace(/^\uFEFF/, '').trim();
|
|
100
|
+
if (content)
|
|
101
|
+
return content;
|
|
102
|
+
// An empty file counts as absent; keep looking at the remaining names.
|
|
103
|
+
}
|
|
104
|
+
catch { /* try the next candidate name */ }
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
89
108
|
function matchesSelector(id, selector) {
|
|
90
109
|
if (selector === '*')
|
|
91
110
|
return true;
|
|
@@ -96,15 +115,26 @@ function matchesSelector(id, selector) {
|
|
|
96
115
|
export class AgentRegistry {
|
|
97
116
|
specs = new Map();
|
|
98
117
|
roots;
|
|
118
|
+
builtinDir;
|
|
119
|
+
userDir;
|
|
120
|
+
projectDir;
|
|
99
121
|
constructor(options = {}) {
|
|
100
122
|
const workspaceRoot = resolve(options.workspaceRoot ?? process.cwd());
|
|
101
|
-
|
|
123
|
+
this.builtinDir = resolve(options.builtinDir ?? resolve(import.meta.dirname, '..', '..', 'agents'));
|
|
124
|
+
this.userDir = resolve(options.userDir ?? resolve(homedir(), '.coder', 'agents'));
|
|
125
|
+
this.projectDir = options.projectDir ? resolve(options.projectDir) : undefined;
|
|
102
126
|
this.roots = [
|
|
103
|
-
{ path: builtinDir, scope: 'builtin' },
|
|
104
|
-
{ path:
|
|
105
|
-
{ path:
|
|
127
|
+
{ path: this.builtinDir, scope: 'builtin' },
|
|
128
|
+
{ path: this.userDir, scope: 'user' },
|
|
129
|
+
{ path: this.projectDir ?? resolve(workspaceRoot, '.coder', 'agents'), scope: 'project' },
|
|
106
130
|
];
|
|
107
131
|
}
|
|
132
|
+
/** Point the project-scope spec root at another workspace (<root>/.coder/agents).
|
|
133
|
+
* Takes effect on the next load(); used by /cd when switching workspaces. */
|
|
134
|
+
setProjectDir(workspaceRoot) {
|
|
135
|
+
this.projectDir = resolve(workspaceRoot, '.coder', 'agents');
|
|
136
|
+
this.roots[this.roots.length - 1] = { path: this.projectDir, scope: 'project' };
|
|
137
|
+
}
|
|
108
138
|
async load() {
|
|
109
139
|
this.specs.clear();
|
|
110
140
|
for (const root of this.roots) {
|
|
@@ -1,14 +1,33 @@
|
|
|
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 mergeAgentUsage(previous, usage, firstTokenMs, durationMs, requests = 1) {
|
|
23
|
+
const merged = mergeUsage(previous, usage ?? {});
|
|
24
|
+
return { ...merged, requests: (previous?.requests ?? 0) + requests, turns: (previous?.turns ?? 0) + 1, firstTokenMs, lastTurnMs: durationMs };
|
|
25
|
+
}
|
|
26
|
+
function sessionChildren(instances, parentId) {
|
|
27
|
+
return [...instances.values()].filter((instance) => instance.parentInstanceId === parentId);
|
|
28
|
+
}
|
|
29
|
+
/** Tools whose success constitutes real progress (state changed on disk). */
|
|
30
|
+
const PROGRESS_TOOLS = new Set(['edit_file', 'write_file']);
|
|
12
31
|
// ── Context compaction ───────────────────────────────────────────────────────
|
|
13
32
|
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
33
|
const DEFAULT_COMPACT_KEEP_RECENT = 12;
|
|
@@ -103,6 +122,11 @@ export class AgentRuntime {
|
|
|
103
122
|
modelStream;
|
|
104
123
|
maxConcurrentTurns;
|
|
105
124
|
maxAgentDepth;
|
|
125
|
+
maxChildrenPerTurn;
|
|
126
|
+
maxSteps;
|
|
127
|
+
projectContext;
|
|
128
|
+
readVersions = new Map();
|
|
129
|
+
failureCounts = new Map();
|
|
106
130
|
defaultModel;
|
|
107
131
|
sessions = new Map();
|
|
108
132
|
instances = new Map();
|
|
@@ -113,19 +137,35 @@ export class AgentRuntime {
|
|
|
113
137
|
running = new Set();
|
|
114
138
|
controllers = new Map();
|
|
115
139
|
idleWaiters = new Set();
|
|
116
|
-
fileLocks
|
|
140
|
+
fileLocks;
|
|
141
|
+
sessionLocks;
|
|
142
|
+
sessionLockHandles = new Map();
|
|
143
|
+
sessionLockHolders = new Map();
|
|
117
144
|
ready;
|
|
118
145
|
shuttingDown = false;
|
|
119
146
|
constructor(options) {
|
|
120
147
|
this.workspaceRoot = resolve(options.workspaceRoot ?? process.cwd());
|
|
121
148
|
this.registry = options.registry ?? new AgentRegistry({ workspaceRoot: this.workspaceRoot });
|
|
122
149
|
this.store = options.store ?? new AgentRuntimeStore();
|
|
150
|
+
// Locks live next to session state so tests (which pass a tmp store) and
|
|
151
|
+
// alternate CODER_DATA_HOME deployments never touch the default lock dir.
|
|
152
|
+
const lockDir = resolve(this.store.runtimeDir, 'locks');
|
|
153
|
+
this.fileLocks = new FileLockManager(lockDir);
|
|
154
|
+
this.sessionLocks = new CrossProcessLockManager(lockDir);
|
|
123
155
|
this.resolveModel = options.resolveModel;
|
|
124
156
|
this.defaultModel = options.defaultModel;
|
|
125
157
|
this.modelStream = options.modelStream ?? ((config, system, messages, tools, signal) => (chatStream(config, system, messages, tools, signal)));
|
|
126
158
|
this.maxConcurrentTurns = Math.max(1, options.maxConcurrentTurns ?? Number(process.env.AGENT_MAX_CONCURRENT_TURNS ?? 4));
|
|
127
159
|
this.maxAgentDepth = Math.max(1, options.maxAgentDepth ?? Number(process.env.AGENT_MAX_DEPTH ?? 4));
|
|
128
|
-
this.
|
|
160
|
+
this.maxChildrenPerTurn = Math.max(1, options.maxChildrenPerTurn ?? Number(process.env.AGENT_MAX_CHILDREN_PER_TURN ?? 3));
|
|
161
|
+
this.maxSteps = options.maxSteps;
|
|
162
|
+
const contextPromise = options.projectContext !== undefined
|
|
163
|
+
? Promise.resolve(options.projectContext)
|
|
164
|
+
: loadWorkspaceContext(this.workspaceRoot);
|
|
165
|
+
this.ready = Promise.all([this.registry.load(), this.store.init(), contextPromise]).then(([, , context]) => {
|
|
166
|
+
this.projectContext = context;
|
|
167
|
+
this.validateSpecs();
|
|
168
|
+
});
|
|
129
169
|
}
|
|
130
170
|
whenReady() {
|
|
131
171
|
return this.ready;
|
|
@@ -164,11 +204,123 @@ export class AgentRuntime {
|
|
|
164
204
|
listAgentSpecs() {
|
|
165
205
|
return this.registry.list();
|
|
166
206
|
}
|
|
207
|
+
/** Absolute workspace root tools resolve relative paths against (/cd target). */
|
|
208
|
+
workspace() {
|
|
209
|
+
return this.workspaceRoot;
|
|
210
|
+
}
|
|
211
|
+
/** Switch the workspace for this runtime (/cd). Resolves `path` against the
|
|
212
|
+
* current root, revalidates it, reloads project agent specs and AGENTS.md,
|
|
213
|
+
* and re-reads project context so every following turn runs in the new root.
|
|
214
|
+
* Ongoing turns keep their already-composed prompts; the change lands on the
|
|
215
|
+
* next turn. */
|
|
216
|
+
async changeWorkspace(path, options = {}) {
|
|
217
|
+
await this.ready;
|
|
218
|
+
const previousRoot = this.workspaceRoot;
|
|
219
|
+
const target = resolve(previousRoot, path.trim());
|
|
220
|
+
let stat;
|
|
221
|
+
try {
|
|
222
|
+
stat = await import('node:fs/promises').then((fs) => fs.stat(target));
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
throw new Error(`cd: no such directory: ${path}`);
|
|
226
|
+
}
|
|
227
|
+
if (!stat.isDirectory())
|
|
228
|
+
throw new Error(`cd: not a directory: ${target}`);
|
|
229
|
+
if (target === previousRoot)
|
|
230
|
+
return { from: previousRoot, to: target };
|
|
231
|
+
this.workspaceRoot = target;
|
|
232
|
+
this.registry.setProjectDir(target);
|
|
233
|
+
this.projectContext = await loadWorkspaceContext(target);
|
|
234
|
+
this.readVersions.clear();
|
|
235
|
+
this.failureCounts.clear();
|
|
236
|
+
try {
|
|
237
|
+
await this.registry.load();
|
|
238
|
+
this.validateSpecs();
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
// Roll back so the runtime never stays half-switched between roots.
|
|
242
|
+
this.workspaceRoot = previousRoot;
|
|
243
|
+
this.registry.setProjectDir(previousRoot);
|
|
244
|
+
this.projectContext = await loadWorkspaceContext(previousRoot).catch(() => undefined);
|
|
245
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
246
|
+
}
|
|
247
|
+
this.emit({ type: 'workspace_changed', sessionId: options.sessionId, workspaceRoot: target, previousRoot });
|
|
248
|
+
return { from: previousRoot, to: target };
|
|
249
|
+
}
|
|
250
|
+
// ── Cross-process session access (single writer, many read-only viewers) ───
|
|
251
|
+
sessionLockTarget(sessionId) {
|
|
252
|
+
return this.store.sessionPath(sessionId);
|
|
253
|
+
}
|
|
254
|
+
sessionLockTimeout() {
|
|
255
|
+
return Math.max(250, Number(process.env.AGENT_SESSION_LOCK_TIMEOUT_MS ?? 5_000));
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Try to become the session's cross-process writer. Returns `writable: false`
|
|
259
|
+
* (instead of throwing) when another process owns the session; the caller
|
|
260
|
+
* opens the session in read-only mode.
|
|
261
|
+
*/
|
|
262
|
+
async ensureSessionLock(sessionId) {
|
|
263
|
+
if (this.sessionLockHandles.has(sessionId))
|
|
264
|
+
return { writable: true };
|
|
265
|
+
try {
|
|
266
|
+
const handle = await this.sessionLocks.acquire(this.sessionLockTarget(sessionId), {
|
|
267
|
+
timeoutMs: this.sessionLockTimeout(),
|
|
268
|
+
purpose: `session:${sessionId}`,
|
|
269
|
+
session: sessionId,
|
|
270
|
+
});
|
|
271
|
+
this.sessionLockHandles.set(sessionId, handle);
|
|
272
|
+
this.sessionLockHolders.delete(sessionId);
|
|
273
|
+
return { writable: true };
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (!(error instanceof LockConflictError))
|
|
277
|
+
throw error;
|
|
278
|
+
const holder = error.holder ?? await this.sessionLocks.holder(this.sessionLockTarget(sessionId));
|
|
279
|
+
if (holder)
|
|
280
|
+
this.sessionLockHolders.set(sessionId, holder);
|
|
281
|
+
return { writable: false, holder };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
async releaseSessionLock(sessionId) {
|
|
285
|
+
const handle = this.sessionLockHandles.get(sessionId);
|
|
286
|
+
if (!handle)
|
|
287
|
+
return;
|
|
288
|
+
this.sessionLockHandles.delete(sessionId);
|
|
289
|
+
this.sessionLockHolders.delete(sessionId);
|
|
290
|
+
await handle.release();
|
|
291
|
+
}
|
|
292
|
+
/** Cross-process access state for UI status display. */
|
|
293
|
+
sessionAccess(sessionId) {
|
|
294
|
+
if (this.sessionLockHandles.has(sessionId))
|
|
295
|
+
return { writable: true };
|
|
296
|
+
if (!this.sessions.has(sessionId))
|
|
297
|
+
return { writable: true }; // never opened here; no restriction known
|
|
298
|
+
const holder = this.sessionLockHolders.get(sessionId);
|
|
299
|
+
if (!holder)
|
|
300
|
+
return { writable: false };
|
|
301
|
+
return { writable: false, holderPid: holder.pid, holderSession: holder.session };
|
|
302
|
+
}
|
|
303
|
+
/** Gate for mutating entry points. Opens the session, then self-heals a
|
|
304
|
+
* read-only state when the previous writer has since exited; otherwise
|
|
305
|
+
* fails with guidance (review or /fork). */
|
|
306
|
+
async requireSessionWrite(sessionId) {
|
|
307
|
+
await this.openSession(sessionId);
|
|
308
|
+
if (this.sessionLockHandles.has(sessionId))
|
|
309
|
+
return;
|
|
310
|
+
const access = await this.ensureSessionLock(sessionId);
|
|
311
|
+
if (access.writable)
|
|
312
|
+
return;
|
|
313
|
+
const holder = access.holder ?? this.sessionLockHolders.get(sessionId);
|
|
314
|
+
const who = holder ? `pid ${holder.pid}${holder.session ? ` (session ${holder.session})` : ''}` : 'another process';
|
|
315
|
+
throw new Error(`Session is read-only: it is currently being written by ${who}. ` +
|
|
316
|
+
'You can keep reviewing the conversation; to continue working from this point, run /fork to get your own writable copy.');
|
|
317
|
+
}
|
|
167
318
|
async openSession(sessionId = `session-${Date.now()}`) {
|
|
168
319
|
await this.ready;
|
|
169
320
|
const current = this.sessions.get(sessionId);
|
|
170
321
|
if (current)
|
|
171
322
|
return cloneSession(current);
|
|
323
|
+
const access = await this.ensureSessionLock(sessionId);
|
|
172
324
|
const persisted = await this.store.load(sessionId);
|
|
173
325
|
if (persisted) {
|
|
174
326
|
const session = persisted.session;
|
|
@@ -180,11 +332,20 @@ export class AgentRuntime {
|
|
|
180
332
|
}
|
|
181
333
|
this.instances.set(instance.instanceId, instance);
|
|
182
334
|
}
|
|
183
|
-
|
|
184
|
-
|
|
335
|
+
// Read-only viewers never schedule recovered turns: the writer process
|
|
336
|
+
// that crashed owned that queue, and re-running it from here would race.
|
|
337
|
+
if (access.writable) {
|
|
338
|
+
for (const instance of persisted.instances.filter((item) => item.status === 'queued'))
|
|
339
|
+
this.enqueue(instance.instanceId);
|
|
340
|
+
}
|
|
185
341
|
this.emit({ type: 'session_opened', session: cloneSession(session) });
|
|
186
342
|
return cloneSession(session);
|
|
187
343
|
}
|
|
344
|
+
if (!access.writable) {
|
|
345
|
+
const holder = access.holder;
|
|
346
|
+
const who = holder ? `pid ${holder.pid}${holder.session ? ` (session ${holder.session})` : ''}` : 'another process';
|
|
347
|
+
throw new Error(`Session ${sessionId} is currently being written by ${who}. Pick a new session id, or wait for the other process to exit.`);
|
|
348
|
+
}
|
|
188
349
|
if (!this.registry.get('main'))
|
|
189
350
|
throw new Error('No main agent spec found');
|
|
190
351
|
const createdAt = now();
|
|
@@ -231,15 +392,36 @@ export class AgentRuntime {
|
|
|
231
392
|
this.queued.delete(id);
|
|
232
393
|
}
|
|
233
394
|
this.sessions.delete(sessionId);
|
|
234
|
-
await this.
|
|
395
|
+
await this.releaseSessionLock(sessionId);
|
|
396
|
+
// Deleting is itself a cross-process mutation: refuse when another
|
|
397
|
+
// process currently owns the session.
|
|
398
|
+
try {
|
|
399
|
+
const handle = await this.sessionLocks.acquire(this.sessionLockTarget(sessionId), {
|
|
400
|
+
timeoutMs: 2_000,
|
|
401
|
+
purpose: `session:${sessionId}:delete`,
|
|
402
|
+
session: sessionId,
|
|
403
|
+
});
|
|
404
|
+
try {
|
|
405
|
+
await this.store.remove(sessionId);
|
|
406
|
+
}
|
|
407
|
+
finally {
|
|
408
|
+
await handle.release();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
if (error instanceof LockConflictError) {
|
|
413
|
+
throw new Error(`Session ${sessionId} cannot be removed while another process is using it (pid ${error.holder?.pid ?? 'unknown'}).`);
|
|
414
|
+
}
|
|
415
|
+
throw error;
|
|
416
|
+
}
|
|
235
417
|
}
|
|
236
418
|
async clearSession(sessionId) {
|
|
237
|
-
|
|
238
|
-
await this.openSession(sessionId);
|
|
419
|
+
await this.requireSessionWrite(sessionId);
|
|
239
420
|
await this.cancelSession(sessionId);
|
|
240
421
|
const session = this.sessions.get(sessionId);
|
|
241
422
|
session.messages = [];
|
|
242
423
|
session.timeline = [];
|
|
424
|
+
session.goal = undefined;
|
|
243
425
|
session.updatedAt = now();
|
|
244
426
|
const main = this.instances.get(session.mainInstanceId);
|
|
245
427
|
if (main) {
|
|
@@ -255,6 +437,8 @@ export class AgentRuntime {
|
|
|
255
437
|
await this.persistSession(sessionId);
|
|
256
438
|
}
|
|
257
439
|
async cancelSession(sessionId) {
|
|
440
|
+
if (!this.sessionLockHandles.has(sessionId))
|
|
441
|
+
await this.requireSessionWrite(sessionId);
|
|
258
442
|
const session = this.sessions.get(sessionId);
|
|
259
443
|
if (!session)
|
|
260
444
|
return;
|
|
@@ -273,11 +457,51 @@ export class AgentRuntime {
|
|
|
273
457
|
await this.persistSession(sessionId);
|
|
274
458
|
this.notifyIdleWaiters();
|
|
275
459
|
}
|
|
460
|
+
/** Set or clear the standing session goal (/goal). Injected into every agent's prompt until cleared. */
|
|
461
|
+
async setSessionGoal(sessionId, goal) {
|
|
462
|
+
const text = goal.trim();
|
|
463
|
+
await this.requireSessionWrite(sessionId);
|
|
464
|
+
const session = this.sessions.get(sessionId);
|
|
465
|
+
session.goal = text || undefined;
|
|
466
|
+
session.updatedAt = now();
|
|
467
|
+
await this.persistSession(sessionId);
|
|
468
|
+
this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system',
|
|
469
|
+
content: text ? `Session goal set: ${text}` : 'Session goal cleared.', createdAt: now() } });
|
|
470
|
+
return { set: Boolean(text), detail: text ? `Goal set. It now applies to every agent in this session: ${text}` : 'Goal cleared.' };
|
|
471
|
+
}
|
|
472
|
+
/** Emit a session-scoped timeline notice that never reaches the model. */
|
|
473
|
+
async emitSystemNotice(sessionId, content) {
|
|
474
|
+
if (!this.sessions.has(sessionId))
|
|
475
|
+
await this.openSession(sessionId);
|
|
476
|
+
this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content, createdAt: now() } });
|
|
477
|
+
}
|
|
478
|
+
/** Fork a session into a new persisted copy (used by /fork and /btw side conversations).
|
|
479
|
+
* Works from a read-only session: the copy is taken from the persisted file
|
|
480
|
+
* and the fork's lock is held while copying so a concurrent opener of the
|
|
481
|
+
* same new id either waits or loses the race cleanly. */
|
|
482
|
+
async forkSession(sessionId, newSessionId = `session-${Date.now()}`) {
|
|
483
|
+
await this.openSession(sessionId);
|
|
484
|
+
await this.persistSession(sessionId);
|
|
485
|
+
const handle = await this.sessionLocks.acquire(this.sessionLockTarget(newSessionId), {
|
|
486
|
+
timeoutMs: this.sessionLockTimeout(),
|
|
487
|
+
purpose: `session:${newSessionId}:fork`,
|
|
488
|
+
session: newSessionId,
|
|
489
|
+
});
|
|
490
|
+
try {
|
|
491
|
+
await this.store.copySession(sessionId, newSessionId);
|
|
492
|
+
}
|
|
493
|
+
finally {
|
|
494
|
+
await handle.release();
|
|
495
|
+
}
|
|
496
|
+
const detail = `Forked into ${newSessionId}. /sessions lists both; the original stays untouched.`;
|
|
497
|
+
this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content: detail, createdAt: now() } });
|
|
498
|
+
return { sessionId: newSessionId, detail };
|
|
499
|
+
}
|
|
276
500
|
async submitMessage(sessionId, content) {
|
|
277
501
|
const text = content.trim();
|
|
278
502
|
if (!text)
|
|
279
503
|
throw new Error('Message cannot be empty');
|
|
280
|
-
await this.
|
|
504
|
+
await this.requireSessionWrite(sessionId);
|
|
281
505
|
const session = this.sessions.get(sessionId);
|
|
282
506
|
const main = this.instances.get(session.mainInstanceId);
|
|
283
507
|
const turnId = randomUUID();
|
|
@@ -309,8 +533,13 @@ export class AgentRuntime {
|
|
|
309
533
|
const ancestors = this.ancestorAgentIds(parent);
|
|
310
534
|
if (ancestors.has(agentId))
|
|
311
535
|
throw new Error(`Agent call cycle rejected: ${agentId} already exists in the ancestor chain`);
|
|
536
|
+
const turnId = parent.activeTurnId;
|
|
537
|
+
const childrenThisTurn = sessionChildren(this.instances, parent.instanceId).filter((child) => child.parentTurnId === turnId).length;
|
|
538
|
+
if (childrenThisTurn >= this.maxChildrenPerTurn)
|
|
539
|
+
throw new Error(`Maximum of ${this.maxChildrenPerTurn} child agents per turn reached; reuse an existing agent or continue directly.`);
|
|
312
540
|
const session = this.sessions.get(parent.sessionId);
|
|
313
541
|
const child = this.newInstance(parent.sessionId, agentId, parent.instanceId, parent.depth + 1);
|
|
542
|
+
child.parentTurnId = turnId;
|
|
314
543
|
this.instances.set(child.instanceId, child);
|
|
315
544
|
parent.childInstanceIds.push(child.instanceId);
|
|
316
545
|
parent.updatedAt = now();
|
|
@@ -467,6 +696,9 @@ export class AgentRuntime {
|
|
|
467
696
|
for (const sessionId of this.sessions.keys())
|
|
468
697
|
await this.persistSession(sessionId);
|
|
469
698
|
await this.store.flush();
|
|
699
|
+
for (const sessionId of [...this.sessionLockHandles.keys()]) {
|
|
700
|
+
await this.releaseSessionLock(sessionId).catch(() => undefined);
|
|
701
|
+
}
|
|
470
702
|
}
|
|
471
703
|
newInstance(sessionId, agentId, parentInstanceId, depth = 0, createdAt = now()) {
|
|
472
704
|
if (!this.registry.get(agentId))
|
|
@@ -567,11 +799,15 @@ export class AgentRuntime {
|
|
|
567
799
|
}
|
|
568
800
|
systemPrompt(instance, spec) {
|
|
569
801
|
const catalog = this.registry.allowedAgents(spec);
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
802
|
+
// Keep the system prefix stable between model calls. Injecting every sibling's
|
|
803
|
+
// live status here invalidates provider prompt caches and burns input tokens;
|
|
804
|
+
// child results are delivered through the mailbox and remain visible in the
|
|
805
|
+
// normal conversation context.
|
|
806
|
+
const session = this.sessions.get(instance.sessionId);
|
|
573
807
|
return [
|
|
574
808
|
spec.instructions,
|
|
809
|
+
...(session?.goal ? ['', `Standing goal for this session (highest priority; stay aligned with it unless the user says otherwise):`, session.goal] : []),
|
|
810
|
+
...(this.projectContext ? ['', `Project context (${WORKSPACE_CONTEXT_LABEL}):`, this.projectContext] : []),
|
|
575
811
|
'',
|
|
576
812
|
'Runtime contract:',
|
|
577
813
|
`- You are agent "${spec.id}" in workspace ${this.workspaceRoot}.`,
|
|
@@ -584,9 +820,7 @@ export class AgentRuntime {
|
|
|
584
820
|
catalog.length
|
|
585
821
|
? `Available agents:\n${catalog.map((agent) => `- ${agent.id}: ${agent.description}`).join('\n')}`
|
|
586
822
|
: 'Available agents: none.',
|
|
587
|
-
|
|
588
|
-
? `Existing instances in this session:\n${relatedInstances.join('\n')}`
|
|
589
|
-
: 'Existing instances in this session: none.',
|
|
823
|
+
'Existing instances are communicated through mailbox messages. Reuse an existing related agent when possible; do not spawn duplicates.',
|
|
590
824
|
].join('\n');
|
|
591
825
|
}
|
|
592
826
|
toolsFor(instance, spec) {
|
|
@@ -784,6 +1018,7 @@ export class AgentRuntime {
|
|
|
784
1018
|
instance.activeTurnId = turnId;
|
|
785
1019
|
instance.status = 'running';
|
|
786
1020
|
instance.updatedAt = now();
|
|
1021
|
+
this.failureCounts.delete(instance.instanceId);
|
|
787
1022
|
this.controllers.set(instance.instanceId, controller);
|
|
788
1023
|
this.absorbMailbox(instance);
|
|
789
1024
|
this.emit({ type: 'instance_updated', instance: cloneInstance(instance) });
|
|
@@ -794,7 +1029,12 @@ export class AgentRuntime {
|
|
|
794
1029
|
throw new Error('No model configured. Use /provider or /model first.');
|
|
795
1030
|
const tools = this.toolsFor(instance, spec);
|
|
796
1031
|
let finalOutput = '';
|
|
797
|
-
|
|
1032
|
+
const turnStartedAt = Date.now();
|
|
1033
|
+
let turnUsage;
|
|
1034
|
+
let firstTokenMs;
|
|
1035
|
+
let requestCount = 0;
|
|
1036
|
+
const stepLimit = Math.max(1, this.maxSteps ?? (instance.parentInstanceId ? 48 : 64));
|
|
1037
|
+
for (let step = 0; step < stepLimit; step += 1) {
|
|
798
1038
|
if (controller.signal.aborted || instance.activeTurnId !== turnId)
|
|
799
1039
|
return;
|
|
800
1040
|
if (instance.pendingCompact || this.shouldAutoCompact(instance.messages, config)) {
|
|
@@ -809,6 +1049,7 @@ export class AgentRuntime {
|
|
|
809
1049
|
catch { /* Compaction is best-effort; trimMessages remains the fallback. */ }
|
|
810
1050
|
}
|
|
811
1051
|
const messages = this.trimMessages(instance.messages, config);
|
|
1052
|
+
requestCount += 1;
|
|
812
1053
|
let text = '';
|
|
813
1054
|
let thinking = '';
|
|
814
1055
|
const responseItems = [];
|
|
@@ -823,12 +1064,16 @@ export class AgentRuntime {
|
|
|
823
1064
|
this.emit({ type: 'thinking_delta', sessionId: session.sessionId, instanceId: instance.instanceId, turnId, text: chunk.thinking });
|
|
824
1065
|
}
|
|
825
1066
|
if (chunk.content) {
|
|
1067
|
+
if (firstTokenMs === undefined)
|
|
1068
|
+
firstTokenMs = Date.now() - turnStartedAt;
|
|
826
1069
|
text += chunk.content;
|
|
827
1070
|
finalOutput += chunk.content;
|
|
828
1071
|
if (!instance.parentInstanceId) {
|
|
829
1072
|
this.emit({ type: 'assistant_delta', sessionId: session.sessionId, instanceId: instance.instanceId, turnId, text: chunk.content });
|
|
830
1073
|
}
|
|
831
1074
|
}
|
|
1075
|
+
if (chunk.usage)
|
|
1076
|
+
turnUsage = mergeUsage(turnUsage, chunk.usage);
|
|
832
1077
|
if (chunk.toolCalls?.length)
|
|
833
1078
|
calls.push(...chunk.toolCalls);
|
|
834
1079
|
}
|
|
@@ -850,6 +1095,31 @@ export class AgentRuntime {
|
|
|
850
1095
|
return;
|
|
851
1096
|
instance.messages.push({ role: 'tool', content: output, tool_use_id: call.id });
|
|
852
1097
|
this.emit({ type: 'tool_finished', instanceId: instance.instanceId, turnId, tool: call.function.name, output });
|
|
1098
|
+
const fingerprint = createHash('sha256')
|
|
1099
|
+
.update(call.function.name)
|
|
1100
|
+
.update('\0')
|
|
1101
|
+
.update(JSON.stringify(args))
|
|
1102
|
+
.update('\0')
|
|
1103
|
+
.update(output)
|
|
1104
|
+
.digest('hex');
|
|
1105
|
+
const failed = /^Error:/i.test(output) || /PolicyError/.test(output);
|
|
1106
|
+
if (failed) {
|
|
1107
|
+
// Progress, not recency, resets the failure chain: counts are kept
|
|
1108
|
+
// per fingerprint, so interleaved successful reads (which prove
|
|
1109
|
+
// nothing changed) cannot launder a repeating failure.
|
|
1110
|
+
const counts = this.failureCounts.get(instance.instanceId) ?? new Map();
|
|
1111
|
+
const repeats = (counts.get(fingerprint) ?? 0) + 1;
|
|
1112
|
+
counts.set(fingerprint, repeats);
|
|
1113
|
+
this.failureCounts.set(instance.instanceId, counts);
|
|
1114
|
+
if (repeats >= 3) {
|
|
1115
|
+
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.`);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
else if (PROGRESS_TOOLS.has(call.function.name)) {
|
|
1119
|
+
// Only a state-changing success (an actual write) counts as
|
|
1120
|
+
// progress; read-only successes leave the failure chain intact.
|
|
1121
|
+
this.failureCounts.delete(instance.instanceId);
|
|
1122
|
+
}
|
|
853
1123
|
}
|
|
854
1124
|
if (instance.pendingCompact && !controller.signal.aborted && instance.activeTurnId === turnId) {
|
|
855
1125
|
try {
|
|
@@ -861,12 +1131,15 @@ export class AgentRuntime {
|
|
|
861
1131
|
}
|
|
862
1132
|
catch { /* Compaction is best-effort. */ }
|
|
863
1133
|
}
|
|
864
|
-
if (step ===
|
|
865
|
-
throw new Error(
|
|
1134
|
+
if (step === stepLimit - 1)
|
|
1135
|
+
throw new Error(`Agent reached the ${stepLimit}-step safety limit. Review the activity and send a follow-up to continue.`);
|
|
866
1136
|
}
|
|
867
1137
|
if (controller.signal.aborted || instance.activeTurnId !== turnId)
|
|
868
1138
|
return;
|
|
869
1139
|
instance.lastOutput = finalOutput.trim() || instance.lastOutput;
|
|
1140
|
+
const endedAt = now();
|
|
1141
|
+
instance.usage = mergeAgentUsage(instance.usage, turnUsage, firstTokenMs, Date.now() - turnStartedAt, requestCount);
|
|
1142
|
+
instance.lastTurn = { startedAt: new Date(turnStartedAt).toISOString(), endedAt, durationMs: Date.now() - turnStartedAt, usage: turnUsage };
|
|
870
1143
|
instance.lastError = undefined;
|
|
871
1144
|
instance.status = 'idle';
|
|
872
1145
|
instance.activeTurnId = undefined;
|
|
@@ -949,6 +1222,19 @@ export class AgentRuntime {
|
|
|
949
1222
|
signal,
|
|
950
1223
|
policy: getToolPolicy(),
|
|
951
1224
|
acquireWriteLock: (path) => this.fileLocks.acquire(path),
|
|
1225
|
+
requirePriorRead: true,
|
|
1226
|
+
getReadVersion: (path) => this.readVersions.get(instance.instanceId)?.get(resolve(path)),
|
|
1227
|
+
recordReadVersion: (path, version) => {
|
|
1228
|
+
let versions = this.readVersions.get(instance.instanceId);
|
|
1229
|
+
if (!versions) {
|
|
1230
|
+
versions = new Map();
|
|
1231
|
+
this.readVersions.set(instance.instanceId, versions);
|
|
1232
|
+
}
|
|
1233
|
+
versions.set(resolve(path), version);
|
|
1234
|
+
},
|
|
1235
|
+
recordWriteVersion: (path, _version) => {
|
|
1236
|
+
this.readVersions.get(instance.instanceId)?.delete(resolve(path));
|
|
1237
|
+
},
|
|
952
1238
|
});
|
|
953
1239
|
}
|
|
954
1240
|
catch (error) {
|
|
@@ -979,6 +1265,10 @@ export class AgentRuntime {
|
|
|
979
1265
|
const session = this.sessions.get(sessionId);
|
|
980
1266
|
if (!session)
|
|
981
1267
|
return;
|
|
1268
|
+
// Read-only viewers never persist: the session file belongs to the
|
|
1269
|
+
// cross-process writer holding the session lock.
|
|
1270
|
+
if (!this.sessionLockHandles.has(sessionId))
|
|
1271
|
+
return;
|
|
982
1272
|
const snapshot = {
|
|
983
1273
|
version: 1,
|
|
984
1274
|
session: cloneSession(session),
|