@cueloop/daemon 0.1.0-alpha.102 → 0.1.0-alpha.103
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/package.json +3 -3
- package/src/agent-tools.ts +6 -0
- package/src/api.ts +63 -0
- package/src/client-reliability.test.ts +2 -0
- package/src/client.ts +24 -2
- package/src/curate.ts +2 -0
- package/src/daemon-sdk.ts +8 -0
- package/src/diff-hot-reload.test.ts +4 -0
- package/src/diff-watcher.test.ts +2 -0
- package/src/diff-watcher.ts +33 -0
- package/src/examples/daemon-sdk.ts +2 -0
- package/src/gh.ts +4 -0
- package/src/ghostty-thread-surface-store.ts +3 -0
- package/src/ghostty-thread-surface.test.ts +2 -0
- package/src/ghostty-thread-surface.ts +6 -0
- package/src/harness-diagnostics.ts +1 -0
- package/src/harness-handoff.test.ts +725 -0
- package/src/harness-handoff.ts +27 -0
- package/src/harness-state-store.ts +5 -0
- package/src/herdr-split.ts +5 -0
- package/src/herdr-thread-surface.ts +14 -0
- package/src/interruptible-wait.ts +3 -0
- package/src/jj-working-tree.ts +9 -0
- package/src/main.ts +1 -0
- package/src/operation-receipts.ts +6 -0
- package/src/pr-poller.ts +7 -0
- package/src/project-files.ts +4 -0
- package/src/protocol.ts +4 -0
- package/src/retention.ts +8 -0
- package/src/server.ts +36 -7
- package/src/share-blob.test.ts +2 -0
- package/src/single-instance.test.ts +3 -0
- package/src/store.ts +11 -0
- package/src/thread-agent-validation.ts +20 -0
- package/src/thread-agent.test.ts +14 -1
- package/src/thread-agent.ts +353 -24
- package/src/thread-review.test.ts +4 -0
- package/src/thread-review.ts +8 -0
- package/src/thread-subscription.test.ts +3 -0
- package/src/thread-subscription.ts +9 -0
- package/src/validate.test.ts +4 -0
- package/src/validate.ts +3 -0
- package/src/vcs-source.ts +6 -0
- package/src/working-tree.ts +4 -0
package/src/thread-agent.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
recordPromptOperation,
|
|
26
26
|
settlePromptOperations,
|
|
27
27
|
} from "./operation-receipts";
|
|
28
|
+
import { harnessHandoffPrompt, harnessContinuationPrompt } from "./harness-handoff";
|
|
28
29
|
import { writeHarnessDiagnostic } from "./harness-diagnostics";
|
|
29
30
|
import { stepAgentTurn, agentTurnCancelled, type AgentTurn } from "./agent-turn";
|
|
30
31
|
import { ThreadAgentSchema } from "./thread-agent-validation";
|
|
@@ -39,6 +40,7 @@ export interface ThreadAgentOptions {
|
|
|
39
40
|
getThread: (id: string) => Thread;
|
|
40
41
|
onChange: (id: string) => void;
|
|
41
42
|
tools?: (thread: Thread) => AgentHarnessTools;
|
|
43
|
+
defaultHarnessForThread?: (thread: Thread) => AgentHarnessAdapter;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
interface ActiveAgent {
|
|
@@ -46,6 +48,8 @@ interface ActiveAgent {
|
|
|
46
48
|
turn: AgentTurn;
|
|
47
49
|
turnId: string;
|
|
48
50
|
revision: number;
|
|
51
|
+
attemptId?: string;
|
|
52
|
+
handoffText?: Map<string, string>;
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
/** Own harness processes and durable agent transcripts without mutating reviewed artifacts. */
|
|
@@ -73,6 +77,7 @@ export class ThreadAgentManager {
|
|
|
73
77
|
/** Refuse agent API calls while its experimental flag is disabled. */
|
|
74
78
|
assertEnabled(id: string): void {
|
|
75
79
|
if (this.disposed) throw new Error("Thread agent is shutting down");
|
|
80
|
+
|
|
76
81
|
if (!this.isEnabled(id))
|
|
77
82
|
throw new Error(
|
|
78
83
|
"Thread agent is disabled; enable [experimental] thread_agent in config.toml",
|
|
@@ -85,13 +90,16 @@ export class ThreadAgentManager {
|
|
|
85
90
|
const cached = this.states.get(id);
|
|
86
91
|
|
|
87
92
|
if (cached) return structuredClone(cached);
|
|
93
|
+
|
|
88
94
|
const path = this.path(id);
|
|
95
|
+
const initialAdapter =
|
|
96
|
+
this.options.defaultHarnessForThread?.(this.options.getThread(id)) ?? this.options.adapter;
|
|
89
97
|
const state = existsSync(path)
|
|
90
98
|
? v.parse(ThreadAgentSchema, JSON.parse(readFileSync(path, "utf8")))
|
|
91
99
|
: {
|
|
92
100
|
threadId: id,
|
|
93
|
-
harness:
|
|
94
|
-
? { id:
|
|
101
|
+
harness: initialAdapter
|
|
102
|
+
? { id: initialAdapter.id, label: initialAdapter.label }
|
|
95
103
|
: undefined,
|
|
96
104
|
phase: { kind: "idle" as const },
|
|
97
105
|
messages: [],
|
|
@@ -100,26 +108,29 @@ export class ThreadAgentManager {
|
|
|
100
108
|
};
|
|
101
109
|
|
|
102
110
|
if (state.threadId !== id) throw new Error("Thread agent record has the wrong thread identity");
|
|
111
|
+
|
|
112
|
+
const recoverable = this.canRecover(state);
|
|
113
|
+
|
|
103
114
|
if (
|
|
104
115
|
state.phase.kind === "running" ||
|
|
105
116
|
state.phase.kind === "permission" ||
|
|
106
117
|
state.submissions?.some((entry) => entry.status === "queued" || entry.status === "running")
|
|
107
118
|
) {
|
|
108
|
-
state.phase =
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
};
|
|
119
|
+
state.phase = recoverable
|
|
120
|
+
? { kind: "idle" }
|
|
121
|
+
: {
|
|
122
|
+
kind: "failed",
|
|
123
|
+
error: "Agent was interrupted by a daemon restart. Send a message to resume.",
|
|
124
|
+
};
|
|
115
125
|
for (const submission of state.submissions ?? []) {
|
|
116
126
|
if (submission.status === "running" || submission.status === "queued")
|
|
117
|
-
submission.status =
|
|
127
|
+
submission.status = recoverable ? "queued" : "failed";
|
|
118
128
|
}
|
|
119
129
|
for (const tool of state.tools) {
|
|
120
130
|
if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
|
|
121
131
|
}
|
|
122
132
|
}
|
|
133
|
+
|
|
123
134
|
settlePromptOperations(state);
|
|
124
135
|
this.states.set(id, state);
|
|
125
136
|
|
|
@@ -138,8 +149,11 @@ export class ThreadAgentManager {
|
|
|
138
149
|
|
|
139
150
|
return structuredClone(state);
|
|
140
151
|
}
|
|
152
|
+
|
|
141
153
|
if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
|
|
154
|
+
|
|
142
155
|
if (thread.status !== "pending") throw new Error("Thread agent review is already resolved");
|
|
156
|
+
|
|
143
157
|
assertHarnessIdentity(state, this.adapter(state));
|
|
144
158
|
const before = structuredClone(state);
|
|
145
159
|
|
|
@@ -165,7 +179,9 @@ export class ThreadAgentManager {
|
|
|
165
179
|
|
|
166
180
|
if (!submission || submission.status !== "failed")
|
|
167
181
|
throw new Error("Thread agent retry requires a failed submission");
|
|
182
|
+
|
|
168
183
|
delete submission.cancelled;
|
|
184
|
+
submission.attemptId = randomUUID();
|
|
169
185
|
submission.status = stepAgentSubmission(submission.status, "retry", true);
|
|
170
186
|
} else {
|
|
171
187
|
const accepted = new Set(submissions.map((entry) => entry.commentId));
|
|
@@ -174,8 +190,10 @@ export class ThreadAgentManager {
|
|
|
174
190
|
this.enqueueComment(state, thread, annotation.id, enqueue);
|
|
175
191
|
for (const comment of state.comments.filter((entry) => !params.inputOnly && !entry.sent))
|
|
176
192
|
this.enqueueComment(state, thread, comment.id, enqueue);
|
|
193
|
+
|
|
177
194
|
if (params.text.trim()) enqueue({ prompt: params.text.trim(), quote: params.context });
|
|
178
195
|
}
|
|
196
|
+
|
|
179
197
|
recordPromptOperation(state, before, params);
|
|
180
198
|
this.save(state);
|
|
181
199
|
} catch (error) {
|
|
@@ -201,15 +219,28 @@ export class ThreadAgentManager {
|
|
|
201
219
|
value?: string;
|
|
202
220
|
}): Promise<ThreadAgentState> {
|
|
203
221
|
this.assertEnabled(params.id);
|
|
222
|
+
|
|
204
223
|
if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
|
|
224
|
+
|
|
205
225
|
const state = this.mutable(params.id);
|
|
206
226
|
|
|
227
|
+
if (params.configId === "harness" && params.value !== undefined)
|
|
228
|
+
return this.switchHarness(state, params.value);
|
|
229
|
+
|
|
230
|
+
if (state.handoff && params.configId === undefined) {
|
|
231
|
+
this.drain(state);
|
|
232
|
+
|
|
233
|
+
return structuredClone(state);
|
|
234
|
+
}
|
|
235
|
+
|
|
207
236
|
if (state.phase.kind === "running" || state.phase.kind === "permission") {
|
|
208
237
|
// Discovery can read existing choices without reconfiguring an active turn.
|
|
209
238
|
if (params.configId === undefined || params.value === undefined)
|
|
210
239
|
return structuredClone(state);
|
|
240
|
+
|
|
211
241
|
throw new Error("Thread agent configuration waits for the current turn");
|
|
212
242
|
}
|
|
243
|
+
|
|
213
244
|
const active = await this.connect(state, this.options.getThread(params.id), "", 1);
|
|
214
245
|
|
|
215
246
|
if (params.configId === undefined || params.value === undefined) {
|
|
@@ -217,18 +248,188 @@ export class ThreadAgentManager {
|
|
|
217
248
|
|
|
218
249
|
if (current.phase.kind !== "running" && current.phase.kind !== "permission")
|
|
219
250
|
active.turn = stepAgentTurn(active.turn, "finished");
|
|
251
|
+
|
|
220
252
|
this.drain(state);
|
|
221
253
|
|
|
222
254
|
return structuredClone(state);
|
|
223
255
|
}
|
|
256
|
+
|
|
224
257
|
if (!active.connection.configure)
|
|
225
258
|
throw new Error("Thread agent harness does not advertise configuration");
|
|
259
|
+
|
|
226
260
|
await active.connection.configure(params.configId, params.value);
|
|
227
261
|
this.save(state);
|
|
228
262
|
|
|
229
263
|
return structuredClone(state);
|
|
230
264
|
}
|
|
231
265
|
|
|
266
|
+
private switchHarness(state: ThreadAgentState, target: string): ThreadAgentState {
|
|
267
|
+
const adapter = this.options.adapters?.[target];
|
|
268
|
+
|
|
269
|
+
if (!adapter || adapter.recovery !== "durable")
|
|
270
|
+
throw new Error("Thread harness is unavailable for durable handoff");
|
|
271
|
+
|
|
272
|
+
if (state.handoff && (state.handoff.status !== "queued" || state.phase.kind === "running")) {
|
|
273
|
+
state.handoff.requestedTarget = target;
|
|
274
|
+
this.save(state);
|
|
275
|
+
|
|
276
|
+
return structuredClone(state);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (this.adapter(state).id === target) state.handoff = undefined;
|
|
280
|
+
else
|
|
281
|
+
state.handoff = {
|
|
282
|
+
target,
|
|
283
|
+
operationId: randomUUID(),
|
|
284
|
+
status: "queued",
|
|
285
|
+
source: state.harness ? { ...state.harness } : undefined,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
this.save(state);
|
|
289
|
+
this.drain(state);
|
|
290
|
+
|
|
291
|
+
return structuredClone(state);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private handoffCurrent(state: ThreadAgentState): boolean {
|
|
295
|
+
return !this.disposed && this.states.get(state.threadId) === state;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private coalesceQueuedHandoff(
|
|
299
|
+
state: ThreadAgentState,
|
|
300
|
+
handoff: NonNullable<ThreadAgentState["handoff"]>,
|
|
301
|
+
): ThreadAgentState["handoff"] {
|
|
302
|
+
if (handoff.status !== "queued" || !handoff.requestedTarget) return handoff;
|
|
303
|
+
|
|
304
|
+
const latest = { ...handoff, target: handoff.requestedTarget, requestedTarget: undefined };
|
|
305
|
+
|
|
306
|
+
if (this.adapter(state).id === latest.target) {
|
|
307
|
+
state.handoff = undefined;
|
|
308
|
+
this.save(state);
|
|
309
|
+
this.drain(state);
|
|
310
|
+
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
state.handoff = latest;
|
|
315
|
+
|
|
316
|
+
return latest;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private async performHandoff(state: ThreadAgentState): Promise<void> {
|
|
320
|
+
let handoff = state.handoff;
|
|
321
|
+
|
|
322
|
+
if (!this.handoffCurrent(state)) return;
|
|
323
|
+
|
|
324
|
+
if (!handoff || state.phase.kind === "running" || state.phase.kind === "permission") return;
|
|
325
|
+
|
|
326
|
+
handoff = this.coalesceQueuedHandoff(state, handoff);
|
|
327
|
+
|
|
328
|
+
if (!handoff) return;
|
|
329
|
+
|
|
330
|
+
state.phase = { kind: "running" };
|
|
331
|
+
this.save(state);
|
|
332
|
+
let active: ActiveAgent | undefined;
|
|
333
|
+
const original = handoff.status === "queued" ? state.harness : handoff.source;
|
|
334
|
+
const intent = { ...handoff, source: original ? { ...original } : undefined };
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const thread = this.options.getThread(state.threadId);
|
|
338
|
+
const text = await this.prepareHandoff(state, thread, intent);
|
|
339
|
+
|
|
340
|
+
if (!this.handoffCurrent(state)) return;
|
|
341
|
+
|
|
342
|
+
const sessionId = state.harness?.sessionId;
|
|
343
|
+
|
|
344
|
+
state.handoff = {
|
|
345
|
+
...intent,
|
|
346
|
+
requestedTarget: state.handoff?.requestedTarget,
|
|
347
|
+
status: "prepared",
|
|
348
|
+
text,
|
|
349
|
+
};
|
|
350
|
+
this.save(state);
|
|
351
|
+
await this.retire(state.threadId);
|
|
352
|
+
|
|
353
|
+
if (!this.handoffCurrent(state)) return;
|
|
354
|
+
|
|
355
|
+
const target = this.options.adapters![handoff.target]!;
|
|
356
|
+
|
|
357
|
+
state.harness = { id: target.id, label: target.label, sessionId };
|
|
358
|
+
state.configOptions = [];
|
|
359
|
+
active = await this.connect(state, thread, "", 1);
|
|
360
|
+
|
|
361
|
+
if (!this.handoffCurrent(state)) return;
|
|
362
|
+
|
|
363
|
+
active.turn = stepAgentTurn(active.turn, "finished");
|
|
364
|
+
state.continuation = text || undefined;
|
|
365
|
+
const requestedTarget = state.handoff?.requestedTarget;
|
|
366
|
+
|
|
367
|
+
state.handoff =
|
|
368
|
+
requestedTarget && requestedTarget !== target.id
|
|
369
|
+
? {
|
|
370
|
+
target: requestedTarget,
|
|
371
|
+
operationId: randomUUID(),
|
|
372
|
+
status: "queued",
|
|
373
|
+
source: { ...state.harness },
|
|
374
|
+
}
|
|
375
|
+
: undefined;
|
|
376
|
+
state.phase = { kind: "idle" };
|
|
377
|
+
this.save(state);
|
|
378
|
+
this.drain(state);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
await this.retire(state.threadId, active);
|
|
381
|
+
|
|
382
|
+
if (!this.handoffCurrent(state)) return;
|
|
383
|
+
|
|
384
|
+
state.harness = original;
|
|
385
|
+
state.handoff = undefined;
|
|
386
|
+
state.phase = {
|
|
387
|
+
kind: "failed",
|
|
388
|
+
error: error instanceof Error ? error.message : "Thread harness handoff failed",
|
|
389
|
+
};
|
|
390
|
+
this.save(state);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private async prepareHandoff(
|
|
395
|
+
state: ThreadAgentState,
|
|
396
|
+
thread: Thread,
|
|
397
|
+
handoff: NonNullable<ThreadAgentState["handoff"]>,
|
|
398
|
+
): Promise<string> {
|
|
399
|
+
if (handoff.status === "prepared") return handoff.text;
|
|
400
|
+
|
|
401
|
+
if (!state.messages.length) return "";
|
|
402
|
+
|
|
403
|
+
const active = await this.connect(
|
|
404
|
+
state,
|
|
405
|
+
thread,
|
|
406
|
+
handoff.operationId,
|
|
407
|
+
thread.revisions.at(-1)?.revision ?? 1,
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
if (!this.handoffCurrent(state)) throw new Error("Thread handoff was closed");
|
|
411
|
+
|
|
412
|
+
active.turnId = handoff.operationId;
|
|
413
|
+
active.attemptId = undefined;
|
|
414
|
+
active.handoffText = new Map();
|
|
415
|
+
state.handoff = {
|
|
416
|
+
...handoff,
|
|
417
|
+
requestedTarget: state.handoff?.requestedTarget,
|
|
418
|
+
status: "summarizing",
|
|
419
|
+
prompt: handoff.prompt ?? harnessHandoffPrompt(thread, state),
|
|
420
|
+
};
|
|
421
|
+
this.save(state);
|
|
422
|
+
const result = await active.connection.prompt(state.handoff.prompt!, handoff.operationId);
|
|
423
|
+
|
|
424
|
+
if (result.outcome !== "completed") throw new Error("Thread harness handoff did not complete");
|
|
425
|
+
|
|
426
|
+
const text = [...active.handoffText.values()].join("\n").trim();
|
|
427
|
+
|
|
428
|
+
if (!text) throw new Error("Thread harness handoff returned no continuation context");
|
|
429
|
+
|
|
430
|
+
return text;
|
|
431
|
+
}
|
|
432
|
+
|
|
232
433
|
private enqueueComment(
|
|
233
434
|
state: ThreadAgentState,
|
|
234
435
|
thread: Thread,
|
|
@@ -242,6 +443,7 @@ export class ThreadAgentManager {
|
|
|
242
443
|
|
|
243
444
|
if (!input || state.submissions?.some((submission) => submission.commentId === commentId))
|
|
244
445
|
throw new Error("Shared agent comment is unavailable or already sent");
|
|
446
|
+
|
|
245
447
|
enqueue({
|
|
246
448
|
commentId,
|
|
247
449
|
prompt: frozen?.text ?? input.body,
|
|
@@ -249,6 +451,7 @@ export class ThreadAgentManager {
|
|
|
249
451
|
messageId: comment?.messageId,
|
|
250
452
|
context: frozen?.discussion ?? commentDiscussion(state, thread, commentId),
|
|
251
453
|
});
|
|
454
|
+
|
|
252
455
|
if (comment) comment.sent = true;
|
|
253
456
|
}
|
|
254
457
|
|
|
@@ -268,8 +471,10 @@ export class ThreadAgentManager {
|
|
|
268
471
|
128
|
|
269
472
|
)
|
|
270
473
|
throw new Error("Thread agent prototype reached its 128-message limit");
|
|
474
|
+
|
|
271
475
|
if (Buffer.byteLength(input.prompt) > 320_000 || Buffer.byteLength(input.quote ?? "") > 320_000)
|
|
272
476
|
throw new Error("Thread agent input exceeds 320 KiB");
|
|
477
|
+
|
|
273
478
|
const used = state.messages.reduce(
|
|
274
479
|
(total, message) => total + Buffer.byteLength(message.text),
|
|
275
480
|
0,
|
|
@@ -277,6 +482,7 @@ export class ThreadAgentManager {
|
|
|
277
482
|
|
|
278
483
|
if (used + Buffer.byteLength(input.prompt) > 2 * 1024 * 1024)
|
|
279
484
|
throw new Error("Thread agent input exceeds transcript limit");
|
|
485
|
+
|
|
280
486
|
const submission: AgentSubmission = {
|
|
281
487
|
...input,
|
|
282
488
|
id: operationId ?? randomUUID(),
|
|
@@ -304,9 +510,17 @@ export class ThreadAgentManager {
|
|
|
304
510
|
this.retiring.has(state.threadId)
|
|
305
511
|
)
|
|
306
512
|
return;
|
|
513
|
+
|
|
514
|
+
if (state.handoff) {
|
|
515
|
+
void this.performHandoff(state);
|
|
516
|
+
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
307
520
|
const submission = state.submissions?.find((entry) => entry.status === "queued");
|
|
308
521
|
|
|
309
522
|
if (!submission) return;
|
|
523
|
+
|
|
310
524
|
const before = structuredClone(state);
|
|
311
525
|
|
|
312
526
|
submission.status = stepAgentSubmission(submission.status, "start", true);
|
|
@@ -334,14 +548,17 @@ Discussion context: ${submission.context ?? submission.messageId ?? ""}
|
|
|
334
548
|
Input: ${submission.prompt}`;
|
|
335
549
|
|
|
336
550
|
try {
|
|
337
|
-
submission.harnessPrompt =
|
|
551
|
+
submission.harnessPrompt = state.continuation
|
|
552
|
+
? harnessContinuationPrompt(state.continuation, prompt)
|
|
553
|
+
: prompt;
|
|
554
|
+
state.continuation = undefined;
|
|
338
555
|
this.save(state);
|
|
339
556
|
} catch (error) {
|
|
340
557
|
// Acceptance is already durable; a retry can resume the queue before any harness request.
|
|
341
558
|
restoreAgentState(state, before);
|
|
342
559
|
throw error;
|
|
343
560
|
}
|
|
344
|
-
void this.run(state, thread, submission.id, revision,
|
|
561
|
+
void this.run(state, thread, submission.id, revision, submission.harnessPrompt!, submission);
|
|
345
562
|
}
|
|
346
563
|
|
|
347
564
|
/** Attach quote-primary feedback only to a finalized agent message. */
|
|
@@ -351,8 +568,10 @@ Input: ${submission.prompt}`;
|
|
|
351
568
|
|
|
352
569
|
if (!message || !message.complete)
|
|
353
570
|
throw new Error("Thread agent comment requires a completed answer");
|
|
571
|
+
|
|
354
572
|
if (!resolveAnchor(params.comment.anchor, parseBlocks(message.text)))
|
|
355
573
|
throw new Error("Thread agent comment quote does not resolve");
|
|
574
|
+
|
|
356
575
|
const index = state.comments.findIndex((comment) => comment.id === params.comment.id);
|
|
357
576
|
const previous = state.comments[index];
|
|
358
577
|
const root = params.comment.replyTo
|
|
@@ -364,19 +583,24 @@ Input: ${submission.prompt}`;
|
|
|
364
583
|
(!root || root.messageId !== message.id || root.id === params.comment.id)
|
|
365
584
|
)
|
|
366
585
|
throw new Error("Thread agent reply requires a comment on the same answer");
|
|
586
|
+
|
|
367
587
|
if (
|
|
368
588
|
this.isReadOnly(params.id, params.comment.id) ||
|
|
369
589
|
(previous && (previous.sent || previous.messageId !== params.comment.messageId))
|
|
370
590
|
)
|
|
371
591
|
throw new Error("Thread agent delivered comments are immutable");
|
|
592
|
+
|
|
372
593
|
if (Buffer.byteLength(JSON.stringify(params.comment)) > 32_768)
|
|
373
594
|
throw new Error("Thread agent comment exceeds 32 KiB");
|
|
595
|
+
|
|
374
596
|
if (index >= 0) state.comments[index] = { ...params.comment, sent: false };
|
|
375
597
|
else {
|
|
376
598
|
if (state.comments.length >= 128)
|
|
377
599
|
throw new Error("Thread agent prototype reached its comment limit");
|
|
600
|
+
|
|
378
601
|
state.comments.push({ ...params.comment, sent: false });
|
|
379
602
|
}
|
|
603
|
+
|
|
380
604
|
this.save(state);
|
|
381
605
|
|
|
382
606
|
return structuredClone(state);
|
|
@@ -388,10 +612,12 @@ Input: ${submission.prompt}`;
|
|
|
388
612
|
const comment = agentCommentRoot(state, commentId);
|
|
389
613
|
|
|
390
614
|
if (!comment) throw new Error("Thread agent reply comment does not exist");
|
|
615
|
+
|
|
391
616
|
const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
|
|
392
617
|
|
|
393
618
|
if (state.comments.length >= 128)
|
|
394
619
|
throw new Error("Thread agent prototype reached its comment limit");
|
|
620
|
+
|
|
395
621
|
state.comments.push({
|
|
396
622
|
...root,
|
|
397
623
|
id: randomUUID(),
|
|
@@ -415,6 +641,7 @@ Input: ${submission.prompt}`;
|
|
|
415
641
|
const submission = state.submissions?.find((entry) => entry.status === "running");
|
|
416
642
|
|
|
417
643
|
if (submission) submission.cancelled = true;
|
|
644
|
+
|
|
418
645
|
for (const tool of state.tools) {
|
|
419
646
|
if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
|
|
420
647
|
}
|
|
@@ -438,8 +665,10 @@ Input: ${submission.prompt}`;
|
|
|
438
665
|
) {
|
|
439
666
|
throw new Error("Thread agent permission request is no longer pending");
|
|
440
667
|
}
|
|
668
|
+
|
|
441
669
|
if (!state.phase.permission.options.some((option) => option.optionId === params.optionId))
|
|
442
670
|
throw new Error("Thread agent permission option was not advertised");
|
|
671
|
+
|
|
443
672
|
active.connection.permission(params.requestId, params.optionId);
|
|
444
673
|
state.phase = { kind: "running" };
|
|
445
674
|
this.save(state);
|
|
@@ -474,7 +703,9 @@ Input: ${submission.prompt}`;
|
|
|
474
703
|
this.active.delete(id);
|
|
475
704
|
active?.connection.cancel();
|
|
476
705
|
await active?.connection.close();
|
|
706
|
+
|
|
477
707
|
if (state.harness?.sessionId) await this.adapter(state).remove?.(state.harness.sessionId);
|
|
708
|
+
|
|
478
709
|
rmSync(this.path(id), { force: true });
|
|
479
710
|
rmSync(this.path(id) + ".tmp", { force: true });
|
|
480
711
|
rmSync(join(this.directory, `${encodeURIComponent(id)}.diagnostics.ndjson`), { force: true });
|
|
@@ -483,17 +714,39 @@ Input: ${submission.prompt}`;
|
|
|
483
714
|
}
|
|
484
715
|
}
|
|
485
716
|
|
|
717
|
+
private canRecover(state: ThreadAgentState): boolean {
|
|
718
|
+
return this.availableAdapter(state)?.recovery === "durable";
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
private availableAdapter(state: ThreadAgentState): AgentHarnessAdapter | undefined {
|
|
722
|
+
const preferred =
|
|
723
|
+
this.options.defaultHarnessForThread?.(this.options.getThread(state.threadId)) ??
|
|
724
|
+
this.options.adapter;
|
|
725
|
+
|
|
726
|
+
if (!state.harness) return preferred;
|
|
727
|
+
|
|
728
|
+
return (
|
|
729
|
+
this.options.adapters?.[state.harness.id] ??
|
|
730
|
+
(preferred?.id === state.harness.id ? preferred : undefined)
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
486
734
|
private adapter(state: ThreadAgentState): AgentHarnessAdapter {
|
|
487
|
-
const adapter =
|
|
488
|
-
(state.harness && this.options.adapters?.[state.harness.id]) ?? this.options.adapter;
|
|
735
|
+
const adapter = this.availableAdapter(state);
|
|
489
736
|
|
|
490
|
-
if (!adapter)
|
|
737
|
+
if (!adapter)
|
|
738
|
+
throw new Error(
|
|
739
|
+
state.harness && this.options.adapter
|
|
740
|
+
? "Thread is bound to a different harness"
|
|
741
|
+
: "Thread agent harness is not configured",
|
|
742
|
+
);
|
|
491
743
|
|
|
492
744
|
return adapter;
|
|
493
745
|
}
|
|
494
746
|
|
|
495
747
|
private assertReadable(id: string): void {
|
|
496
748
|
if (this.deleting.has(id)) throw new Error("Thread agent is being deleted");
|
|
749
|
+
|
|
497
750
|
this.options.getThread(id);
|
|
498
751
|
}
|
|
499
752
|
|
|
@@ -509,11 +762,14 @@ Input: ${submission.prompt}`;
|
|
|
509
762
|
|
|
510
763
|
private save(state: ThreadAgentState): void {
|
|
511
764
|
settlePromptOperations(state);
|
|
765
|
+
|
|
512
766
|
if (this.states.get(state.threadId) !== state) return;
|
|
767
|
+
|
|
513
768
|
const data = JSON.stringify(state);
|
|
514
769
|
|
|
515
770
|
if (Buffer.byteLength(data) > 8 * 1024 * 1024)
|
|
516
771
|
throw new Error("Thread agent transcript exceeds 8 MiB");
|
|
772
|
+
|
|
517
773
|
mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
518
774
|
writeFileSync(this.path(state.threadId) + ".tmp", data, { mode: 0o600 });
|
|
519
775
|
renameSync(this.path(state.threadId) + ".tmp", this.path(state.threadId));
|
|
@@ -526,38 +782,45 @@ Input: ${submission.prompt}`;
|
|
|
526
782
|
turnId: string,
|
|
527
783
|
revision: number,
|
|
528
784
|
prompt: string,
|
|
529
|
-
comments: AgentComment[],
|
|
530
785
|
submission?: AgentSubmission,
|
|
531
786
|
): Promise<void> {
|
|
532
787
|
let active = this.active.get(state.threadId);
|
|
533
|
-
const messageStart = state.messages.length;
|
|
534
788
|
|
|
535
789
|
try {
|
|
536
790
|
active = await this.connect(state, thread, turnId, revision);
|
|
791
|
+
|
|
537
792
|
if (this.states.get(state.threadId) !== state) return;
|
|
793
|
+
|
|
538
794
|
active.turnId = turnId;
|
|
539
795
|
active.revision = revision;
|
|
796
|
+
active.attemptId = submission?.attemptId;
|
|
797
|
+
|
|
540
798
|
// Cancellation during initialization must not submit a new model request.
|
|
541
799
|
if (active.turn.kind === "idle") {
|
|
542
800
|
if (submission) submission.status = "failed";
|
|
801
|
+
|
|
543
802
|
state.phase = { kind: "idle" };
|
|
544
803
|
this.save(state);
|
|
545
804
|
this.drain(state);
|
|
546
805
|
|
|
547
806
|
return;
|
|
548
807
|
}
|
|
549
|
-
|
|
808
|
+
|
|
809
|
+
const result = await active.connection.prompt(prompt, submission?.attemptId ?? turnId);
|
|
550
810
|
|
|
551
811
|
if (this.states.get(state.threadId) !== state) return;
|
|
552
|
-
|
|
812
|
+
|
|
813
|
+
finalizeAgentMessages(state, active, result.outcome);
|
|
814
|
+
|
|
553
815
|
if (submission && result.outcome === "cancelled") submission.cancelled = true;
|
|
816
|
+
|
|
554
817
|
if (submission)
|
|
555
818
|
submission.status = stepAgentSubmission(
|
|
556
819
|
submission.status,
|
|
557
820
|
result.outcome === "completed" ? "complete" : "fail",
|
|
558
821
|
true,
|
|
559
822
|
);
|
|
560
|
-
|
|
823
|
+
|
|
561
824
|
for (const tool of state.tools) {
|
|
562
825
|
if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
|
|
563
826
|
}
|
|
@@ -567,11 +830,13 @@ Input: ${submission.prompt}`;
|
|
|
567
830
|
this.drain(state);
|
|
568
831
|
} catch (error) {
|
|
569
832
|
if (submission) submission.status = "failed";
|
|
833
|
+
|
|
570
834
|
state.phase = {
|
|
571
835
|
kind: "failed",
|
|
572
836
|
error: error instanceof Error ? error.message : "Thread agent request failed",
|
|
573
837
|
};
|
|
574
838
|
await this.retire(state.threadId, active);
|
|
839
|
+
|
|
575
840
|
if (!this.disposed && this.states.get(state.threadId) === state) {
|
|
576
841
|
this.save(state);
|
|
577
842
|
this.drain(state);
|
|
@@ -583,6 +848,7 @@ Input: ${submission.prompt}`;
|
|
|
583
848
|
const existing = this.retiring.get(id);
|
|
584
849
|
|
|
585
850
|
if (existing) return existing;
|
|
851
|
+
|
|
586
852
|
this.active.delete(id);
|
|
587
853
|
const closing = Promise.resolve()
|
|
588
854
|
.then(() => active?.connection.close())
|
|
@@ -605,10 +871,13 @@ Input: ${submission.prompt}`;
|
|
|
605
871
|
const retiring = this.retiring.get(state.threadId);
|
|
606
872
|
|
|
607
873
|
if (retiring) await retiring;
|
|
874
|
+
|
|
608
875
|
if (this.disposed) throw new Error("Thread agent is shutting down");
|
|
876
|
+
|
|
609
877
|
const pending = this.initializing.get(state.threadId);
|
|
610
878
|
|
|
611
879
|
if (pending) return pending;
|
|
880
|
+
|
|
612
881
|
const promise = this.startConnection(state, thread, turnId, revision);
|
|
613
882
|
|
|
614
883
|
this.initializing.set(state.threadId, promise);
|
|
@@ -619,6 +888,22 @@ Input: ${submission.prompt}`;
|
|
|
619
888
|
}
|
|
620
889
|
}
|
|
621
890
|
|
|
891
|
+
private handoffTools(state: ThreadAgentState, thread: Thread): AgentHarnessTools | undefined {
|
|
892
|
+
const tools = this.options.tools?.(thread);
|
|
893
|
+
|
|
894
|
+
if (!tools) return undefined;
|
|
895
|
+
|
|
896
|
+
return {
|
|
897
|
+
...tools,
|
|
898
|
+
call: (name, input) => {
|
|
899
|
+
if (state.handoff?.status === "summarizing")
|
|
900
|
+
throw new Error("Thread tools are unavailable while preparing a harness handoff");
|
|
901
|
+
|
|
902
|
+
return tools.call(name, input);
|
|
903
|
+
},
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
622
907
|
private async startConnection(
|
|
623
908
|
state: ThreadAgentState,
|
|
624
909
|
thread: Thread,
|
|
@@ -632,11 +917,13 @@ Input: ${submission.prompt}`;
|
|
|
632
917
|
const connection = adapter.connect({
|
|
633
918
|
cwd: thread.artifact.meta.cwd ?? thread.workspace.repoRoot,
|
|
634
919
|
sessionId: state.harness?.sessionId,
|
|
635
|
-
tools: this.
|
|
920
|
+
tools: this.handoffTools(state, thread),
|
|
636
921
|
onEvent: (event) => this.receive(state, event),
|
|
637
922
|
onExit: (error) => {
|
|
638
923
|
if (this.active.get(state.threadId)?.connection !== connection) return;
|
|
924
|
+
|
|
639
925
|
this.active.delete(state.threadId);
|
|
926
|
+
|
|
640
927
|
if (!this.disposed) {
|
|
641
928
|
state.phase = { kind: "failed", error: error.message };
|
|
642
929
|
this.save(state);
|
|
@@ -649,6 +936,7 @@ Input: ${submission.prompt}`;
|
|
|
649
936
|
const sessionId = await connection.start();
|
|
650
937
|
|
|
651
938
|
if (this.states.get(state.threadId) !== state) throw new Error("Thread agent was removed");
|
|
939
|
+
|
|
652
940
|
state.harness = {
|
|
653
941
|
id: adapter.id,
|
|
654
942
|
label: adapter.label,
|
|
@@ -664,7 +952,20 @@ Input: ${submission.prompt}`;
|
|
|
664
952
|
}
|
|
665
953
|
|
|
666
954
|
private receive(state: ThreadAgentState, event: AgentHarnessEvent): void {
|
|
955
|
+
const handoff = this.active.get(state.threadId)?.handoffText;
|
|
956
|
+
|
|
957
|
+
if (handoff && event.kind === "message") {
|
|
958
|
+
const id = event.id ?? "summary";
|
|
959
|
+
|
|
960
|
+
handoff.set(id, event.replace ? event.text : (handoff.get(id) ?? "") + event.text);
|
|
961
|
+
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (handoff && event.kind === "tool") return;
|
|
966
|
+
|
|
667
967
|
if (this.states.get(state.threadId) !== state) return;
|
|
968
|
+
|
|
668
969
|
const routed = routeHarnessOutput(event);
|
|
669
970
|
|
|
670
971
|
if (routed.destination === "diagnostics") {
|
|
@@ -672,27 +973,33 @@ Input: ${submission.prompt}`;
|
|
|
672
973
|
|
|
673
974
|
return;
|
|
674
975
|
}
|
|
976
|
+
|
|
675
977
|
event = routed.event;
|
|
978
|
+
|
|
676
979
|
if (event.kind === "config") {
|
|
677
980
|
state.configOptions = event.options;
|
|
678
981
|
this.save(state);
|
|
679
982
|
|
|
680
983
|
return;
|
|
681
984
|
}
|
|
985
|
+
|
|
682
986
|
const active = this.active.get(state.threadId);
|
|
683
987
|
|
|
684
988
|
if (!active || active.turn.kind === "starting") return;
|
|
989
|
+
|
|
685
990
|
if (event.kind === "permission") {
|
|
686
991
|
if (agentTurnCancelled(active.turn)) {
|
|
687
992
|
active.connection.permission(event.permission.id);
|
|
688
993
|
|
|
689
994
|
return;
|
|
690
995
|
}
|
|
996
|
+
|
|
691
997
|
state.phase = { kind: "permission", permission: event.permission };
|
|
692
998
|
this.save(state);
|
|
693
999
|
|
|
694
1000
|
return;
|
|
695
1001
|
}
|
|
1002
|
+
|
|
696
1003
|
this.applyUpdate(state, active, event);
|
|
697
1004
|
}
|
|
698
1005
|
|
|
@@ -702,7 +1009,9 @@ Input: ${submission.prompt}`;
|
|
|
702
1009
|
update: Extract<AgentHarnessEvent, { kind: "message" | "tool" }>,
|
|
703
1010
|
): void {
|
|
704
1011
|
if (update.kind === "message") {
|
|
705
|
-
const messageId =
|
|
1012
|
+
const messageId = active.attemptId
|
|
1013
|
+
? `${active.attemptId}:${update.id ?? "answer"}`
|
|
1014
|
+
: (update.id ?? `answer-${active.turnId}`);
|
|
706
1015
|
let message = state.messages.find((message) => message.id === messageId);
|
|
707
1016
|
|
|
708
1017
|
if (!message) {
|
|
@@ -716,7 +1025,9 @@ Input: ${submission.prompt}`;
|
|
|
716
1025
|
};
|
|
717
1026
|
state.messages.push(message);
|
|
718
1027
|
}
|
|
1028
|
+
|
|
719
1029
|
if (message.complete) return;
|
|
1030
|
+
|
|
720
1031
|
const used = state.messages.reduce(
|
|
721
1032
|
(total, entry) => total + Buffer.byteLength(entry.text),
|
|
722
1033
|
0,
|
|
@@ -743,6 +1054,7 @@ Input: ${submission.prompt}`;
|
|
|
743
1054
|
if (!tool) {
|
|
744
1055
|
if (state.tools.length >= 128)
|
|
745
1056
|
throw new Error("Thread agent reached its tool activity limit");
|
|
1057
|
+
|
|
746
1058
|
tool = {
|
|
747
1059
|
id: update.id,
|
|
748
1060
|
turnId: active.turnId,
|
|
@@ -754,16 +1066,22 @@ Input: ${submission.prompt}`;
|
|
|
754
1066
|
};
|
|
755
1067
|
state.tools.push(tool);
|
|
756
1068
|
}
|
|
1069
|
+
|
|
757
1070
|
if (update.title) tool.title = update.title.slice(0, 256);
|
|
1071
|
+
|
|
758
1072
|
if (update.toolKind) tool.kind = update.toolKind;
|
|
1073
|
+
|
|
759
1074
|
if (update.status)
|
|
760
1075
|
tool.status = agentTurnCancelled(active.turn) ? "cancelled" : update.status;
|
|
1076
|
+
|
|
761
1077
|
if (update.locations)
|
|
762
1078
|
tool.locations = update.locations
|
|
763
1079
|
.slice(0, 8)
|
|
764
1080
|
.map((location) => ({ ...location, path: location.path.slice(0, 512) }));
|
|
1081
|
+
|
|
765
1082
|
if (update.output !== undefined) tool.output = update.output.slice(0, 8192);
|
|
766
1083
|
}
|
|
1084
|
+
|
|
767
1085
|
this.save(state);
|
|
768
1086
|
}
|
|
769
1087
|
}
|
|
@@ -771,11 +1089,18 @@ Input: ${submission.prompt}`;
|
|
|
771
1089
|
/** Only this turn can finalize its answers; stopped and historical partials stay incomplete. */
|
|
772
1090
|
function finalizeAgentMessages(
|
|
773
1091
|
state: ThreadAgentState,
|
|
774
|
-
|
|
1092
|
+
active: ActiveAgent,
|
|
775
1093
|
outcome: AgentHarnessResult["outcome"],
|
|
776
1094
|
): void {
|
|
777
1095
|
if (outcome !== "completed") return;
|
|
778
|
-
|
|
1096
|
+
|
|
1097
|
+
for (const message of state.messages) {
|
|
1098
|
+
if (message.role !== "agent" || message.submissionId !== active.turnId) continue;
|
|
1099
|
+
|
|
1100
|
+
if (active.attemptId && !message.id.startsWith(`${active.attemptId}:`)) continue;
|
|
1101
|
+
|
|
1102
|
+
message.complete = true;
|
|
1103
|
+
}
|
|
779
1104
|
}
|
|
780
1105
|
|
|
781
1106
|
function isPendingAgentInput(
|
|
@@ -792,8 +1117,11 @@ function isPendingAgentInput(
|
|
|
792
1117
|
|
|
793
1118
|
function restoreAgentState(state: ThreadAgentState, before: ThreadAgentState): void {
|
|
794
1119
|
if (before.submissions === undefined) delete state.submissions;
|
|
1120
|
+
|
|
795
1121
|
if (before.promptOperations === undefined) delete state.promptOperations;
|
|
1122
|
+
|
|
796
1123
|
if (before.harness === undefined) delete state.harness;
|
|
1124
|
+
|
|
797
1125
|
Object.assign(state, before);
|
|
798
1126
|
}
|
|
799
1127
|
|
|
@@ -831,6 +1159,7 @@ function commentDiscussion(state: ThreadAgentState, thread: Thread, commentId: s
|
|
|
831
1159
|
.map((entry) => ({ id: entry.id, body: entry.body })),
|
|
832
1160
|
});
|
|
833
1161
|
}
|
|
1162
|
+
|
|
834
1163
|
const comment = agentCommentRoot(state, commentId)!;
|
|
835
1164
|
const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
|
|
836
1165
|
|