killeros 1.5.7 → 2.0.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/CHANGELOG.md +8 -119
- package/Killeros.ts +1 -25
- package/README.md +130 -220
- package/killeros/commands.ts +1 -238
- package/killeros/display.ts +6 -2
- package/killeros/footer.ts +8 -3
- package/killeros/goals.ts +10 -9
- package/killeros/hooks.ts +1 -1
- package/killeros/limits.ts +1 -0
- package/killeros/personal-instructions.ts +3 -1
- package/killeros/question.ts +19 -4
- package/package.json +2 -8
- package/agents/debugger.md +0 -50
- package/agents/documenter.md +0 -49
- package/agents/planner.md +0 -53
- package/agents/reviewer.md +0 -58
- package/agents/scout.md +0 -56
- package/agents/security.md +0 -54
- package/agents/tester.md +0 -50
- package/agents/worker.md +0 -54
- package/killeros/subagent-lifecycle.ts +0 -761
- package/killeros/subagent-persistence.ts +0 -572
- package/killeros/subagent-process.ts +0 -626
- package/killeros/subagent-ui.ts +0 -245
- package/killeros/subagents.ts +0 -3048
- package/subagent-lifecycle.ts +0 -1
- package/subagent-process.ts +0 -1
- package/subagent-ui.ts +0 -1
- package/subagents.ts +0 -1
|
@@ -1,761 +0,0 @@
|
|
|
1
|
-
/** A dependency-free lifecycle model for child agent threads. */
|
|
2
|
-
|
|
3
|
-
declare const threadIdBrand: unique symbol;
|
|
4
|
-
|
|
5
|
-
export type SubagentThreadId = string & { readonly [threadIdBrand]: "SubagentThreadId" };
|
|
6
|
-
export type SubagentThreadState = "queued" | "active" | "done" | "failed" | "stopped" | "orphaned" | "closed";
|
|
7
|
-
export type SubagentTerminalState = Extract<SubagentThreadState, "done" | "failed" | "stopped" | "orphaned">;
|
|
8
|
-
export type SubagentFilesystemAccess = "none" | "read" | "write";
|
|
9
|
-
export type SubagentNetworkAccess = "none" | "read" | "full";
|
|
10
|
-
export type SubagentProcessAccess = "none" | "limited" | "full";
|
|
11
|
-
|
|
12
|
-
export interface SubagentCapabilityBoundary {
|
|
13
|
-
filesystem: SubagentFilesystemAccess;
|
|
14
|
-
network: SubagentNetworkAccess;
|
|
15
|
-
process: SubagentProcessAccess;
|
|
16
|
-
childThreads: boolean;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface SubagentHandoff {
|
|
20
|
-
summary: string;
|
|
21
|
-
nextAction?: string;
|
|
22
|
-
artifacts?: readonly string[];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface SubagentUsage {
|
|
26
|
-
inputTokens: number;
|
|
27
|
-
outputTokens: number;
|
|
28
|
-
cacheReadTokens: number;
|
|
29
|
-
cacheWriteTokens: number;
|
|
30
|
-
totalTokens: number;
|
|
31
|
-
costUsd: number;
|
|
32
|
-
turns: number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface SubagentTraceEvent {
|
|
36
|
-
at: number;
|
|
37
|
-
kind: string;
|
|
38
|
-
message?: string;
|
|
39
|
-
details?: Readonly<Record<string, string | number | boolean | null>>;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface SubagentTraceUpdate {
|
|
43
|
-
kind: string;
|
|
44
|
-
message?: string;
|
|
45
|
-
details?: Readonly<Record<string, string | number | boolean | null>>;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface SubagentSteeringMessage {
|
|
49
|
-
id: number;
|
|
50
|
-
at: number;
|
|
51
|
-
message: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface SubagentThreadTimestamps {
|
|
55
|
-
createdAt: number;
|
|
56
|
-
updatedAt: number;
|
|
57
|
-
startedAt?: number;
|
|
58
|
-
endedAt?: number;
|
|
59
|
-
closedAt?: number;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface SubagentThreadSession {
|
|
63
|
-
id: string;
|
|
64
|
-
directory: string;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface SubagentThreadSpec {
|
|
68
|
-
parentId?: SubagentThreadId;
|
|
69
|
-
displayName: string;
|
|
70
|
-
role: string;
|
|
71
|
-
prompt: string;
|
|
72
|
-
model: string;
|
|
73
|
-
tools: readonly string[];
|
|
74
|
-
capabilityBoundary: SubagentCapabilityBoundary;
|
|
75
|
-
session: SubagentThreadSession;
|
|
76
|
-
handoff?: SubagentHandoff;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface SubagentThreadPatch {
|
|
80
|
-
usage?: Partial<SubagentUsage>;
|
|
81
|
-
handoff?: SubagentHandoff | null;
|
|
82
|
-
result?: string | null;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface SubagentCompletion extends SubagentThreadPatch {
|
|
86
|
-
result?: string;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export interface SubagentFailure extends SubagentThreadPatch {
|
|
90
|
-
message: string;
|
|
91
|
-
code?: string;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export interface SubagentStop extends SubagentThreadPatch {
|
|
95
|
-
reason?: string;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface SubagentThread extends SubagentThreadSpec {
|
|
99
|
-
id: SubagentThreadId;
|
|
100
|
-
attempt: number;
|
|
101
|
-
state: SubagentThreadState;
|
|
102
|
-
usage: SubagentUsage;
|
|
103
|
-
trace: SubagentTraceEvent[];
|
|
104
|
-
steering: SubagentSteeringMessage[];
|
|
105
|
-
result?: string;
|
|
106
|
-
failure?: { message: string; code?: string };
|
|
107
|
-
stopReason?: string;
|
|
108
|
-
/** True when close evicted the heavy trace, prompt, handoff, and result fields. */
|
|
109
|
-
evicted: boolean;
|
|
110
|
-
timestamps: SubagentThreadTimestamps;
|
|
111
|
-
version: number;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export type SubagentThreadChangeType =
|
|
115
|
-
| "spawn"
|
|
116
|
-
| "begin"
|
|
117
|
-
| "patch"
|
|
118
|
-
| "trace"
|
|
119
|
-
| "steer"
|
|
120
|
-
| "complete"
|
|
121
|
-
| "fail"
|
|
122
|
-
| "stop"
|
|
123
|
-
| "interrupt"
|
|
124
|
-
| "resume"
|
|
125
|
-
| "close";
|
|
126
|
-
|
|
127
|
-
export interface SubagentThreadChange {
|
|
128
|
-
type: SubagentThreadChangeType;
|
|
129
|
-
thread: SubagentThread;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export interface SubagentThreadRegistryOptions {
|
|
133
|
-
createId?: () => string;
|
|
134
|
-
now?: () => number;
|
|
135
|
-
maxSteeringMessages?: number;
|
|
136
|
-
maxSteeringMessageLength?: number;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export type SubagentThreadListener = (change: SubagentThreadChange) => void;
|
|
140
|
-
|
|
141
|
-
export interface SubagentWaitResult {
|
|
142
|
-
threadIds: readonly SubagentThreadId[];
|
|
143
|
-
completedThreadIds: readonly SubagentThreadId[];
|
|
144
|
-
pendingThreadIds: readonly SubagentThreadId[];
|
|
145
|
-
timedOut: boolean;
|
|
146
|
-
waitedMs: number;
|
|
147
|
-
threads: readonly SubagentThread[];
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const DEFAULT_MAX_STEERING_MESSAGES = 20;
|
|
151
|
-
const DEFAULT_MAX_STEERING_MESSAGE_LENGTH = 4_000;
|
|
152
|
-
const UPDATABLE_STATES = new Set<SubagentThreadState>(["queued", "active"]);
|
|
153
|
-
const TERMINAL_STATES = new Set<SubagentThreadState>(["done", "failed", "stopped", "orphaned"]);
|
|
154
|
-
const DISPLAY_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._ -]{0,47}$/u;
|
|
155
|
-
const MAX_WAIT_TIMEOUT_MS = 2_147_483_647;
|
|
156
|
-
const USAGE_FIELDS = [
|
|
157
|
-
"inputTokens",
|
|
158
|
-
"outputTokens",
|
|
159
|
-
"cacheReadTokens",
|
|
160
|
-
"cacheWriteTokens",
|
|
161
|
-
"totalTokens",
|
|
162
|
-
"costUsd",
|
|
163
|
-
"turns",
|
|
164
|
-
] as const;
|
|
165
|
-
|
|
166
|
-
function emptyUsage(): SubagentUsage {
|
|
167
|
-
return {
|
|
168
|
-
inputTokens: 0,
|
|
169
|
-
outputTokens: 0,
|
|
170
|
-
cacheReadTokens: 0,
|
|
171
|
-
cacheWriteTokens: 0,
|
|
172
|
-
totalTokens: 0,
|
|
173
|
-
costUsd: 0,
|
|
174
|
-
turns: 0,
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function copyHandoff(handoff: SubagentHandoff | undefined): SubagentHandoff | undefined {
|
|
179
|
-
if (!handoff) return undefined;
|
|
180
|
-
return {
|
|
181
|
-
summary: handoff.summary,
|
|
182
|
-
nextAction: handoff.nextAction,
|
|
183
|
-
artifacts: handoff.artifacts ? [...handoff.artifacts] : undefined,
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function copyBoundary(boundary: SubagentCapabilityBoundary): SubagentCapabilityBoundary {
|
|
188
|
-
return { ...boundary };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function copySession(session: SubagentThreadSession): SubagentThreadSession {
|
|
192
|
-
return { id: session.id, directory: session.directory };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function copyTraceEvent(event: SubagentTraceEvent): SubagentTraceEvent {
|
|
196
|
-
return {
|
|
197
|
-
at: event.at,
|
|
198
|
-
kind: event.kind,
|
|
199
|
-
message: event.message,
|
|
200
|
-
details: event.details ? { ...event.details } : undefined,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function snapshot(thread: SubagentThread): SubagentThread {
|
|
205
|
-
return {
|
|
206
|
-
id: thread.id,
|
|
207
|
-
parentId: thread.parentId,
|
|
208
|
-
displayName: thread.displayName,
|
|
209
|
-
role: thread.role,
|
|
210
|
-
prompt: thread.prompt,
|
|
211
|
-
model: thread.model,
|
|
212
|
-
tools: [...thread.tools],
|
|
213
|
-
capabilityBoundary: copyBoundary(thread.capabilityBoundary),
|
|
214
|
-
session: copySession(thread.session),
|
|
215
|
-
handoff: copyHandoff(thread.handoff),
|
|
216
|
-
attempt: thread.attempt,
|
|
217
|
-
state: thread.state,
|
|
218
|
-
usage: { ...thread.usage },
|
|
219
|
-
trace: thread.trace.map(copyTraceEvent),
|
|
220
|
-
steering: thread.steering.map((message) => ({ ...message })),
|
|
221
|
-
result: thread.result,
|
|
222
|
-
failure: thread.failure ? { ...thread.failure } : undefined,
|
|
223
|
-
stopReason: thread.stopReason,
|
|
224
|
-
evicted: thread.evicted,
|
|
225
|
-
timestamps: { ...thread.timestamps },
|
|
226
|
-
version: thread.version,
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function requireText(value: string, name: string): void {
|
|
231
|
-
if (typeof value !== "string" || !value.trim()) throw new Error(`${name} must be non-empty`);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function requirePositiveInteger(value: number, name: string): void {
|
|
235
|
-
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function validateUsage(patch: Partial<SubagentUsage>): void {
|
|
239
|
-
for (const field of USAGE_FIELDS) {
|
|
240
|
-
const value = patch[field];
|
|
241
|
-
if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
|
|
242
|
-
throw new Error(`usage.${field} must be a non-negative finite number`);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function validateHandoff(handoff: SubagentHandoff): void {
|
|
248
|
-
if (!handoff || typeof handoff !== "object") throw new Error("handoff must be an object");
|
|
249
|
-
requireText(handoff.summary, "handoff.summary");
|
|
250
|
-
if (handoff.nextAction !== undefined) requireText(handoff.nextAction, "handoff.nextAction");
|
|
251
|
-
if (handoff.artifacts !== undefined && !Array.isArray(handoff.artifacts)) throw new Error("handoff.artifacts must be an array");
|
|
252
|
-
for (const artifact of handoff.artifacts ?? []) requireText(artifact, "handoff artifact");
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function validateDisplayName(displayName: string): void {
|
|
256
|
-
if (typeof displayName !== "string" || !DISPLAY_NAME_PATTERN.test(displayName)) {
|
|
257
|
-
throw new Error("display name must match ^[A-Za-z0-9][A-Za-z0-9._ -]{0,47}$");
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function validateSession(session: SubagentThreadSession): void {
|
|
262
|
-
if (!session || typeof session !== "object") throw new Error("session must be an object");
|
|
263
|
-
requireText(session.id, "session.id");
|
|
264
|
-
requireText(session.directory, "session.directory");
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function validateBoundary(boundary: SubagentCapabilityBoundary): void {
|
|
268
|
-
if (!boundary || typeof boundary !== "object") throw new Error("capabilityBoundary must be an object");
|
|
269
|
-
if (!(["none", "read", "write"] as string[]).includes(boundary.filesystem)) {
|
|
270
|
-
throw new Error("capabilityBoundary.filesystem must be none, read, or write");
|
|
271
|
-
}
|
|
272
|
-
if (!(["none", "read", "full"] as string[]).includes(boundary.network)) {
|
|
273
|
-
throw new Error("capabilityBoundary.network must be none, read, or full");
|
|
274
|
-
}
|
|
275
|
-
if (!(["none", "limited", "full"] as string[]).includes(boundary.process)) {
|
|
276
|
-
throw new Error("capabilityBoundary.process must be none, limited, or full");
|
|
277
|
-
}
|
|
278
|
-
if (typeof boundary.childThreads !== "boolean") throw new Error("capabilityBoundary.childThreads must be a boolean");
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function validateTraceDetails(details: Readonly<Record<string, string | number | boolean | null>> | undefined): void {
|
|
282
|
-
if (details === undefined) return;
|
|
283
|
-
if (!details || typeof details !== "object" || Array.isArray(details)) throw new Error("trace.details must be an object");
|
|
284
|
-
for (const value of Object.values(details)) {
|
|
285
|
-
if (value !== null && !["string", "number", "boolean"].includes(typeof value)) {
|
|
286
|
-
throw new Error("trace.details values must be strings, numbers, booleans, or null");
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function validateTraceEvent(event: SubagentTraceEvent, index: number): void {
|
|
292
|
-
if (!event || typeof event !== "object") throw new Error(`trace[${index}] must be an object`);
|
|
293
|
-
if (!Number.isFinite(event.at)) throw new Error(`trace[${index}].at must be a finite number`);
|
|
294
|
-
requireText(event.kind, `trace[${index}].kind`);
|
|
295
|
-
if (event.message !== undefined) requireText(event.message, `trace[${index}].message`);
|
|
296
|
-
validateTraceDetails(event.details);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function validateSteeringMessage(message: SubagentSteeringMessage, index: number): void {
|
|
300
|
-
if (!message || typeof message !== "object") throw new Error(`steering[${index}] must be an object`);
|
|
301
|
-
requirePositiveInteger(message.id, `steering[${index}].id`);
|
|
302
|
-
if (!Number.isFinite(message.at)) throw new Error(`steering[${index}].at must be a finite number`);
|
|
303
|
-
requireText(message.message, `steering[${index}].message`);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function validateCompleteUsage(usage: SubagentUsage): void {
|
|
307
|
-
if (!usage || typeof usage !== "object") throw new Error("usage must be an object");
|
|
308
|
-
for (const field of USAGE_FIELDS) {
|
|
309
|
-
if (!(field in usage)) throw new Error(`usage.${field} is required`);
|
|
310
|
-
}
|
|
311
|
-
validateUsage(usage);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function validateTimestamps(timestamps: SubagentThreadTimestamps): void {
|
|
315
|
-
if (!timestamps || typeof timestamps !== "object") throw new Error("timestamps must be an object");
|
|
316
|
-
for (const field of ["createdAt", "updatedAt", "startedAt", "endedAt", "closedAt"] as const) {
|
|
317
|
-
const value = timestamps[field];
|
|
318
|
-
if (value !== undefined && !Number.isFinite(value)) throw new Error(`timestamps.${field} must be a finite number`);
|
|
319
|
-
}
|
|
320
|
-
if (timestamps.createdAt === undefined) throw new Error("timestamps.createdAt is required");
|
|
321
|
-
if (timestamps.updatedAt === undefined) throw new Error("timestamps.updatedAt is required");
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function isTerminal(state: SubagentThreadState): state is SubagentTerminalState {
|
|
325
|
-
return TERMINAL_STATES.has(state);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// Module-scoped so fresh registries keep assigning fresh ids across session replacements.
|
|
329
|
-
let nextId = 0;
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Owns child-thread state only. Callers execute, cancel, and transport work.
|
|
333
|
-
* Each read returns a copy, so callers cannot mutate registry state.
|
|
334
|
-
*/
|
|
335
|
-
export class SubagentThreadRegistry {
|
|
336
|
-
private readonly threads = new Map<SubagentThreadId, SubagentThread>();
|
|
337
|
-
private readonly listeners = new Set<SubagentThreadListener>();
|
|
338
|
-
private readonly now: () => number;
|
|
339
|
-
private readonly createId: () => string;
|
|
340
|
-
private readonly maxSteeringMessages: number;
|
|
341
|
-
private readonly maxSteeringMessageLength: number;
|
|
342
|
-
private nextSteeringId = 0;
|
|
343
|
-
private disposed = false;
|
|
344
|
-
|
|
345
|
-
constructor(options: SubagentThreadRegistryOptions = {}) {
|
|
346
|
-
this.now = options.now ?? Date.now;
|
|
347
|
-
this.maxSteeringMessages = options.maxSteeringMessages ?? DEFAULT_MAX_STEERING_MESSAGES;
|
|
348
|
-
this.maxSteeringMessageLength = options.maxSteeringMessageLength ?? DEFAULT_MAX_STEERING_MESSAGE_LENGTH;
|
|
349
|
-
requirePositiveInteger(this.maxSteeringMessages, "maxSteeringMessages");
|
|
350
|
-
requirePositiveInteger(this.maxSteeringMessageLength, "maxSteeringMessageLength");
|
|
351
|
-
this.createId = options.createId ?? (() => `subagent-${++nextId}`);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
get isDisposed(): boolean {
|
|
355
|
-
return this.disposed;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
spawn(spec: SubagentThreadSpec): SubagentThread {
|
|
359
|
-
this.assertOpen();
|
|
360
|
-
this.validateSpec(spec);
|
|
361
|
-
this.assertUniqueDisplayName(spec.displayName, spec.parentId);
|
|
362
|
-
const rawId = this.createId();
|
|
363
|
-
requireText(rawId, "thread id");
|
|
364
|
-
const id = rawId as SubagentThreadId;
|
|
365
|
-
if (this.threads.has(id)) throw new Error(`Duplicate thread id ${rawId}`);
|
|
366
|
-
|
|
367
|
-
const timestamp = this.now();
|
|
368
|
-
const thread: SubagentThread = {
|
|
369
|
-
id,
|
|
370
|
-
parentId: spec.parentId,
|
|
371
|
-
displayName: spec.displayName,
|
|
372
|
-
role: spec.role,
|
|
373
|
-
prompt: spec.prompt,
|
|
374
|
-
model: spec.model,
|
|
375
|
-
tools: [...spec.tools],
|
|
376
|
-
capabilityBoundary: copyBoundary(spec.capabilityBoundary),
|
|
377
|
-
session: copySession(spec.session),
|
|
378
|
-
handoff: copyHandoff(spec.handoff),
|
|
379
|
-
attempt: 1,
|
|
380
|
-
state: "queued",
|
|
381
|
-
usage: emptyUsage(),
|
|
382
|
-
trace: [],
|
|
383
|
-
steering: [],
|
|
384
|
-
evicted: false,
|
|
385
|
-
timestamps: { createdAt: timestamp, updatedAt: timestamp },
|
|
386
|
-
version: 1,
|
|
387
|
-
};
|
|
388
|
-
this.threads.set(id, thread);
|
|
389
|
-
this.emit("spawn", thread);
|
|
390
|
-
return snapshot(thread);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
begin(id: SubagentThreadId): SubagentThread {
|
|
394
|
-
const thread = this.requireState(id, ["queued"]);
|
|
395
|
-
thread.state = "active";
|
|
396
|
-
thread.timestamps.startedAt = this.now();
|
|
397
|
-
this.changed(thread, "begin");
|
|
398
|
-
return snapshot(thread);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
patch(id: SubagentThreadId, patch: SubagentThreadPatch): SubagentThread {
|
|
402
|
-
const thread = this.requireState(id, UPDATABLE_STATES);
|
|
403
|
-
this.applyPatch(thread, patch);
|
|
404
|
-
this.changed(thread, "patch");
|
|
405
|
-
return snapshot(thread);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
trace(id: SubagentThreadId, update: SubagentTraceUpdate): SubagentThread {
|
|
409
|
-
const thread = this.requireState(id, UPDATABLE_STATES);
|
|
410
|
-
requireText(update.kind, "trace.kind");
|
|
411
|
-
if (update.message !== undefined) requireText(update.message, "trace.message");
|
|
412
|
-
if (update.details) {
|
|
413
|
-
for (const value of Object.values(update.details)) {
|
|
414
|
-
if (value !== null && !["string", "number", "boolean"].includes(typeof value)) {
|
|
415
|
-
throw new Error("trace.details values must be strings, numbers, booleans, or null");
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
thread.trace.push({ at: this.now(), kind: update.kind, message: update.message, details: update.details ? { ...update.details } : undefined });
|
|
420
|
-
this.changed(thread, "trace");
|
|
421
|
-
return snapshot(thread);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
steer(id: SubagentThreadId, message: string): SubagentThread {
|
|
425
|
-
const thread = this.requireState(id, UPDATABLE_STATES);
|
|
426
|
-
requireText(message, "steering message");
|
|
427
|
-
if (message.length > this.maxSteeringMessageLength) {
|
|
428
|
-
throw new Error(`steering message exceeds ${this.maxSteeringMessageLength} characters`);
|
|
429
|
-
}
|
|
430
|
-
thread.steering.push({ id: ++this.nextSteeringId, at: this.now(), message });
|
|
431
|
-
if (thread.steering.length > this.maxSteeringMessages) thread.steering.splice(this.maxSteeringMessages);
|
|
432
|
-
this.changed(thread, "steer");
|
|
433
|
-
return snapshot(thread);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
complete(id: SubagentThreadId, completion: SubagentCompletion = {}): SubagentThread {
|
|
437
|
-
const thread = this.requireState(id, ["active"]);
|
|
438
|
-
const result = completion.result !== undefined ? completion.result : thread.result;
|
|
439
|
-
if (result === undefined || result === null) throw new Error(`Cannot complete thread ${id} without a usable result`);
|
|
440
|
-
requireText(result, "result");
|
|
441
|
-
this.applyPatch(thread, completion);
|
|
442
|
-
thread.state = "done";
|
|
443
|
-
thread.timestamps.endedAt = this.now();
|
|
444
|
-
this.changed(thread, "complete");
|
|
445
|
-
return snapshot(thread);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
fail(id: SubagentThreadId, failure: SubagentFailure): SubagentThread {
|
|
449
|
-
const thread = this.requireState(id, ["active"]);
|
|
450
|
-
requireText(failure.message, "failure.message");
|
|
451
|
-
if (failure.code !== undefined) requireText(failure.code, "failure.code");
|
|
452
|
-
this.applyPatch(thread, failure);
|
|
453
|
-
thread.failure = { message: failure.message, code: failure.code };
|
|
454
|
-
thread.state = "failed";
|
|
455
|
-
thread.timestamps.endedAt = this.now();
|
|
456
|
-
this.changed(thread, "fail");
|
|
457
|
-
return snapshot(thread);
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
stop(id: SubagentThreadId, stop: SubagentStop = {}): SubagentThread {
|
|
461
|
-
const thread = this.requireState(id, UPDATABLE_STATES);
|
|
462
|
-
if (stop.reason !== undefined) requireText(stop.reason, "stop.reason");
|
|
463
|
-
this.applyPatch(thread, stop);
|
|
464
|
-
thread.stopReason = stop.reason ?? "stopped";
|
|
465
|
-
thread.state = "stopped";
|
|
466
|
-
thread.timestamps.endedAt = this.now();
|
|
467
|
-
this.changed(thread, "stop");
|
|
468
|
-
return snapshot(thread);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
interrupt(id: SubagentThreadId, reason = "interrupted"): SubagentThread {
|
|
472
|
-
const thread = this.requireState(id, ["active"]);
|
|
473
|
-
requireText(reason, "interrupt reason");
|
|
474
|
-
thread.stopReason = reason;
|
|
475
|
-
thread.state = "stopped";
|
|
476
|
-
thread.timestamps.endedAt = this.now();
|
|
477
|
-
this.changed(thread, "interrupt");
|
|
478
|
-
return snapshot(thread);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
stopAllActive(stop: SubagentStop = {}): SubagentThread[] {
|
|
482
|
-
return this.listActive().map((thread) => this.stop(thread.id, stop));
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
interruptAllActive(reason = "interrupted"): SubagentThread[] {
|
|
486
|
-
return this.listActive().map((thread) => this.interrupt(thread.id, reason));
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
inspect(id: SubagentThreadId): SubagentThread | undefined {
|
|
490
|
-
const thread = this.threads.get(id);
|
|
491
|
-
return thread ? snapshot(thread) : undefined;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
resolve(reference: string, parentId?: SubagentThreadId): SubagentThread | undefined {
|
|
495
|
-
if (typeof reference !== "string") return undefined;
|
|
496
|
-
const exact = this.threads.get(reference as SubagentThreadId);
|
|
497
|
-
if (exact) return snapshot(exact);
|
|
498
|
-
const name = reference.toLocaleLowerCase();
|
|
499
|
-
const match = [...this.threads.values()].find((thread) =>
|
|
500
|
-
thread.parentId === parentId && thread.displayName.toLocaleLowerCase() === name,
|
|
501
|
-
);
|
|
502
|
-
return match ? snapshot(match) : undefined;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
hydrate(thread: SubagentThread): SubagentThread {
|
|
506
|
-
this.assertOpen();
|
|
507
|
-
this.validateThreadSnapshot(thread);
|
|
508
|
-
if (this.threads.has(thread.id)) throw new Error(`Duplicate thread id ${thread.id}`);
|
|
509
|
-
this.assertUniqueDisplayName(thread.displayName, thread.parentId);
|
|
510
|
-
|
|
511
|
-
const hydrated = snapshot(thread);
|
|
512
|
-
if (hydrated.state === "queued" || hydrated.state === "active") {
|
|
513
|
-
hydrated.state = "orphaned";
|
|
514
|
-
hydrated.stopReason = "parent_restarted";
|
|
515
|
-
}
|
|
516
|
-
this.threads.set(hydrated.id, hydrated);
|
|
517
|
-
for (const message of hydrated.steering) this.nextSteeringId = Math.max(this.nextSteeringId, message.id);
|
|
518
|
-
return snapshot(hydrated);
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
resume(id: SubagentThreadId, prompt?: string): SubagentThread {
|
|
522
|
-
const thread = this.requireState(id, ["done", "failed", "stopped", "orphaned"]);
|
|
523
|
-
if (prompt !== undefined) {
|
|
524
|
-
requireText(prompt, "prompt");
|
|
525
|
-
thread.prompt = prompt;
|
|
526
|
-
}
|
|
527
|
-
thread.attempt += 1;
|
|
528
|
-
thread.result = undefined;
|
|
529
|
-
thread.failure = undefined;
|
|
530
|
-
thread.stopReason = undefined;
|
|
531
|
-
delete thread.timestamps.startedAt;
|
|
532
|
-
delete thread.timestamps.endedAt;
|
|
533
|
-
thread.state = "queued";
|
|
534
|
-
this.changed(thread, "resume");
|
|
535
|
-
return snapshot(thread);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
waitForTerminal(ids: readonly SubagentThreadId[], timeoutMs: number): Promise<SubagentWaitResult> {
|
|
539
|
-
const threadIds = [...ids];
|
|
540
|
-
if (threadIds.length === 0) {
|
|
541
|
-
return Promise.resolve({
|
|
542
|
-
threadIds,
|
|
543
|
-
completedThreadIds: [],
|
|
544
|
-
pendingThreadIds: [],
|
|
545
|
-
timedOut: false,
|
|
546
|
-
waitedMs: 0,
|
|
547
|
-
threads: [],
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > MAX_WAIT_TIMEOUT_MS) {
|
|
551
|
-
throw new Error(`timeoutMs must be an integer from 0 to ${MAX_WAIT_TIMEOUT_MS}`);
|
|
552
|
-
}
|
|
553
|
-
for (const id of threadIds) this.requireThread(id);
|
|
554
|
-
|
|
555
|
-
const startedAt = Date.now();
|
|
556
|
-
const currentThreads = (): SubagentThread[] => threadIds.map((id) => snapshot(this.threads.get(id)!));
|
|
557
|
-
const makeResult = (timedOut: boolean): SubagentWaitResult => {
|
|
558
|
-
const threads = currentThreads();
|
|
559
|
-
const completedThreadIds = threads.filter((thread) => isTerminal(thread.state)).map((thread) => thread.id);
|
|
560
|
-
return {
|
|
561
|
-
threadIds,
|
|
562
|
-
completedThreadIds,
|
|
563
|
-
pendingThreadIds: threads.filter((thread) => !isTerminal(thread.state)).map((thread) => thread.id),
|
|
564
|
-
timedOut,
|
|
565
|
-
waitedMs: Math.max(0, Date.now() - startedAt),
|
|
566
|
-
threads,
|
|
567
|
-
};
|
|
568
|
-
};
|
|
569
|
-
const allTerminal = (): boolean => threadIds.every((id) => isTerminal(this.threads.get(id)!.state));
|
|
570
|
-
if (allTerminal()) return Promise.resolve(makeResult(false));
|
|
571
|
-
|
|
572
|
-
return new Promise<SubagentWaitResult>((resolve) => {
|
|
573
|
-
let settled = false;
|
|
574
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
575
|
-
let unsubscribe = (): void => {};
|
|
576
|
-
const finish = (timedOut: boolean): void => {
|
|
577
|
-
if (settled) return;
|
|
578
|
-
settled = true;
|
|
579
|
-
unsubscribe();
|
|
580
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
581
|
-
resolve(makeResult(timedOut));
|
|
582
|
-
};
|
|
583
|
-
unsubscribe = this.subscribe((change) => {
|
|
584
|
-
if (threadIds.includes(change.thread.id) && allTerminal()) finish(false);
|
|
585
|
-
});
|
|
586
|
-
timer = setTimeout(() => finish(true), timeoutMs);
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
listActive(): SubagentThread[] {
|
|
591
|
-
return this.list((thread) => thread.state === "active");
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
/** Returns all terminal records: done, failed, stopped, and orphaned. */
|
|
595
|
-
listDone(): SubagentThread[] {
|
|
596
|
-
return this.list((thread) => isTerminal(thread.state));
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
listAll(): SubagentThread[] {
|
|
600
|
-
return this.list(() => true);
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/** Returns a terminal snapshot without removing the record. */
|
|
604
|
-
collect(id: SubagentThreadId): SubagentThread {
|
|
605
|
-
const thread = this.requireThread(id);
|
|
606
|
-
if (!isTerminal(thread.state)) throw new Error(`Thread ${id} is ${thread.state}, not terminal`);
|
|
607
|
-
return snapshot(thread);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/** Closes a terminal record and retains only a small tombstone for inspection. */
|
|
611
|
-
close(id: SubagentThreadId): SubagentThread {
|
|
612
|
-
this.assertOpen();
|
|
613
|
-
const thread = this.requireThread(id);
|
|
614
|
-
if (thread.state === "closed") return snapshot(thread);
|
|
615
|
-
if (!isTerminal(thread.state)) throw new Error(`Cannot close thread ${id} from ${thread.state}`);
|
|
616
|
-
thread.state = "closed";
|
|
617
|
-
thread.timestamps.closedAt = this.now();
|
|
618
|
-
thread.prompt = "[closed thread prompt evicted]";
|
|
619
|
-
thread.handoff = undefined;
|
|
620
|
-
thread.trace = [];
|
|
621
|
-
thread.steering = [];
|
|
622
|
-
thread.result = undefined;
|
|
623
|
-
thread.failure = thread.failure ? { message: thread.failure.message.slice(0, 512), code: thread.failure.code } : undefined;
|
|
624
|
-
thread.evicted = true;
|
|
625
|
-
this.changed(thread, "close");
|
|
626
|
-
return snapshot(thread);
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
/** Remove the oldest closed tombstones and return bounded eviction notices. */
|
|
630
|
-
pruneClosed(maxRecords: number): SubagentThread[] {
|
|
631
|
-
this.assertOpen();
|
|
632
|
-
const closed = [...this.threads.values()]
|
|
633
|
-
.filter((thread) => thread.state === "closed")
|
|
634
|
-
.sort((left, right) => (left.timestamps.closedAt ?? left.timestamps.updatedAt) - (right.timestamps.closedAt ?? right.timestamps.updatedAt));
|
|
635
|
-
const removed: SubagentThread[] = [];
|
|
636
|
-
while (closed.length > Math.max(0, Math.floor(maxRecords))) {
|
|
637
|
-
const thread = closed.shift()!;
|
|
638
|
-
this.threads.delete(thread.id);
|
|
639
|
-
removed.push(snapshot(thread));
|
|
640
|
-
}
|
|
641
|
-
return removed;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
subscribe(listener: SubagentThreadListener): () => void {
|
|
645
|
-
if (this.disposed) return () => {};
|
|
646
|
-
this.listeners.add(listener);
|
|
647
|
-
return () => this.listeners.delete(listener);
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
dispose(): void {
|
|
651
|
-
if (this.disposed) return;
|
|
652
|
-
for (const thread of this.listAll().filter((candidate) => candidate.state === "queued" || candidate.state === "active")) {
|
|
653
|
-
this.stop(thread.id, { reason: "disposed" });
|
|
654
|
-
}
|
|
655
|
-
for (const thread of this.listDone()) this.close(thread.id);
|
|
656
|
-
this.listeners.clear();
|
|
657
|
-
this.disposed = true;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
private validateSpec(spec: SubagentThreadSpec): void {
|
|
661
|
-
if (!spec || typeof spec !== "object") throw new Error("thread spec must be an object");
|
|
662
|
-
if (spec.parentId !== undefined) requireText(spec.parentId, "parent id");
|
|
663
|
-
validateDisplayName(spec.displayName);
|
|
664
|
-
requireText(spec.role, "role");
|
|
665
|
-
requireText(spec.prompt, "prompt");
|
|
666
|
-
requireText(spec.model, "model");
|
|
667
|
-
validateBoundary(spec.capabilityBoundary);
|
|
668
|
-
validateSession(spec.session);
|
|
669
|
-
if (!Array.isArray(spec.tools)) throw new Error("tools must be an array");
|
|
670
|
-
const tools = new Set<string>();
|
|
671
|
-
for (const tool of spec.tools) {
|
|
672
|
-
requireText(tool, "tool");
|
|
673
|
-
if (tools.has(tool)) throw new Error(`Duplicate tool ${tool}`);
|
|
674
|
-
tools.add(tool);
|
|
675
|
-
}
|
|
676
|
-
if (spec.handoff) validateHandoff(spec.handoff);
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
private validateThreadSnapshot(thread: SubagentThread): void {
|
|
680
|
-
if (!thread || typeof thread !== "object") throw new Error("thread snapshot must be an object");
|
|
681
|
-
requireText(thread.id, "thread id");
|
|
682
|
-
this.validateSpec(thread);
|
|
683
|
-
if (!(["queued", "active", "done", "failed", "stopped", "orphaned", "closed"] as string[]).includes(thread.state)) {
|
|
684
|
-
throw new Error(`Unknown thread state ${thread.state}`);
|
|
685
|
-
}
|
|
686
|
-
requirePositiveInteger(thread.attempt, "attempt");
|
|
687
|
-
validateCompleteUsage(thread.usage);
|
|
688
|
-
if (!Array.isArray(thread.trace)) throw new Error("trace must be an array");
|
|
689
|
-
thread.trace.forEach(validateTraceEvent);
|
|
690
|
-
if (!Array.isArray(thread.steering)) throw new Error("steering must be an array");
|
|
691
|
-
thread.steering.forEach(validateSteeringMessage);
|
|
692
|
-
if (thread.result !== undefined) requireText(thread.result, "result");
|
|
693
|
-
if (thread.failure !== undefined) {
|
|
694
|
-
if (!thread.failure || typeof thread.failure !== "object") throw new Error("failure must be an object");
|
|
695
|
-
requireText(thread.failure.message, "failure.message");
|
|
696
|
-
if (thread.failure.code !== undefined) requireText(thread.failure.code, "failure.code");
|
|
697
|
-
}
|
|
698
|
-
if (thread.stopReason !== undefined) requireText(thread.stopReason, "stopReason");
|
|
699
|
-
if (typeof thread.evicted !== "boolean") throw new Error("evicted must be a boolean");
|
|
700
|
-
validateTimestamps(thread.timestamps);
|
|
701
|
-
requirePositiveInteger(thread.version, "version");
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
private applyPatch(thread: SubagentThread, patch: SubagentThreadPatch): void {
|
|
705
|
-
if (patch.usage) {
|
|
706
|
-
validateUsage(patch.usage);
|
|
707
|
-
Object.assign(thread.usage, patch.usage);
|
|
708
|
-
}
|
|
709
|
-
if (patch.handoff !== undefined) {
|
|
710
|
-
if (patch.handoff) validateHandoff(patch.handoff);
|
|
711
|
-
thread.handoff = copyHandoff(patch.handoff ?? undefined);
|
|
712
|
-
}
|
|
713
|
-
if (patch.result !== undefined) {
|
|
714
|
-
if (patch.result !== null) requireText(patch.result, "result");
|
|
715
|
-
thread.result = patch.result ?? undefined;
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
private list(matches: (thread: SubagentThread) => boolean): SubagentThread[] {
|
|
720
|
-
return [...this.threads.values()].filter(matches).map(snapshot);
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
private assertUniqueDisplayName(displayName: string, parentId?: SubagentThreadId): void {
|
|
724
|
-
const name = displayName.toLocaleLowerCase();
|
|
725
|
-
if ([...this.threads.values()].some((thread) =>
|
|
726
|
-
thread.parentId === parentId && thread.displayName.toLocaleLowerCase() === name,
|
|
727
|
-
)) {
|
|
728
|
-
throw new Error(`display name ${displayName} already exists for this parent`);
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
private requireThread(id: SubagentThreadId): SubagentThread {
|
|
733
|
-
this.assertOpen();
|
|
734
|
-
const thread = this.threads.get(id);
|
|
735
|
-
if (!thread) throw new Error(`Unknown thread ${id}`);
|
|
736
|
-
return thread;
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
private requireState(id: SubagentThreadId, allowed: Iterable<SubagentThreadState>): SubagentThread {
|
|
740
|
-
const thread = this.requireThread(id);
|
|
741
|
-
const allowedStates = [...allowed];
|
|
742
|
-
if (!allowedStates.includes(thread.state)) {
|
|
743
|
-
throw new Error(`Cannot change thread ${id} from ${thread.state}; expected ${allowedStates.join(" or ")}`);
|
|
744
|
-
}
|
|
745
|
-
return thread;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
private changed(thread: SubagentThread, type: SubagentThreadChangeType): void {
|
|
749
|
-
thread.timestamps.updatedAt = this.now();
|
|
750
|
-
thread.version += 1;
|
|
751
|
-
this.emit(type, thread);
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
private emit(type: SubagentThreadChangeType, thread: SubagentThread): void {
|
|
755
|
-
for (const listener of this.listeners) listener({ type, thread: snapshot(thread) });
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
private assertOpen(): void {
|
|
759
|
-
if (this.disposed) throw new Error("SubagentThreadRegistry is disposed");
|
|
760
|
-
}
|
|
761
|
-
}
|