@cueloop/daemon 0.1.0-alpha.101 → 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 +361 -25
- 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
|
|
|
@@ -152,13 +166,22 @@ export class ThreadAgentManager {
|
|
|
152
166
|
this.enqueue(state, revision, input, promptMirrorIdentity(params));
|
|
153
167
|
|
|
154
168
|
if (params.commentId) {
|
|
155
|
-
|
|
169
|
+
// Shared requests carry an accepted discussion snapshot; local requests read the saved note.
|
|
170
|
+
this.enqueueComment(
|
|
171
|
+
state,
|
|
172
|
+
thread,
|
|
173
|
+
params.commentId,
|
|
174
|
+
enqueue,
|
|
175
|
+
params.discussion === undefined ? undefined : params,
|
|
176
|
+
);
|
|
156
177
|
} else if (params.retry) {
|
|
157
178
|
const submission = submissions.find((entry) => entry.id === params.retry);
|
|
158
179
|
|
|
159
180
|
if (!submission || submission.status !== "failed")
|
|
160
181
|
throw new Error("Thread agent retry requires a failed submission");
|
|
182
|
+
|
|
161
183
|
delete submission.cancelled;
|
|
184
|
+
submission.attemptId = randomUUID();
|
|
162
185
|
submission.status = stepAgentSubmission(submission.status, "retry", true);
|
|
163
186
|
} else {
|
|
164
187
|
const accepted = new Set(submissions.map((entry) => entry.commentId));
|
|
@@ -167,8 +190,10 @@ export class ThreadAgentManager {
|
|
|
167
190
|
this.enqueueComment(state, thread, annotation.id, enqueue);
|
|
168
191
|
for (const comment of state.comments.filter((entry) => !params.inputOnly && !entry.sent))
|
|
169
192
|
this.enqueueComment(state, thread, comment.id, enqueue);
|
|
193
|
+
|
|
170
194
|
if (params.text.trim()) enqueue({ prompt: params.text.trim(), quote: params.context });
|
|
171
195
|
}
|
|
196
|
+
|
|
172
197
|
recordPromptOperation(state, before, params);
|
|
173
198
|
this.save(state);
|
|
174
199
|
} catch (error) {
|
|
@@ -194,15 +219,28 @@ export class ThreadAgentManager {
|
|
|
194
219
|
value?: string;
|
|
195
220
|
}): Promise<ThreadAgentState> {
|
|
196
221
|
this.assertEnabled(params.id);
|
|
222
|
+
|
|
197
223
|
if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
|
|
224
|
+
|
|
198
225
|
const state = this.mutable(params.id);
|
|
199
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
|
+
|
|
200
236
|
if (state.phase.kind === "running" || state.phase.kind === "permission") {
|
|
201
237
|
// Discovery can read existing choices without reconfiguring an active turn.
|
|
202
238
|
if (params.configId === undefined || params.value === undefined)
|
|
203
239
|
return structuredClone(state);
|
|
240
|
+
|
|
204
241
|
throw new Error("Thread agent configuration waits for the current turn");
|
|
205
242
|
}
|
|
243
|
+
|
|
206
244
|
const active = await this.connect(state, this.options.getThread(params.id), "", 1);
|
|
207
245
|
|
|
208
246
|
if (params.configId === undefined || params.value === undefined) {
|
|
@@ -210,18 +248,188 @@ export class ThreadAgentManager {
|
|
|
210
248
|
|
|
211
249
|
if (current.phase.kind !== "running" && current.phase.kind !== "permission")
|
|
212
250
|
active.turn = stepAgentTurn(active.turn, "finished");
|
|
251
|
+
|
|
213
252
|
this.drain(state);
|
|
214
253
|
|
|
215
254
|
return structuredClone(state);
|
|
216
255
|
}
|
|
256
|
+
|
|
217
257
|
if (!active.connection.configure)
|
|
218
258
|
throw new Error("Thread agent harness does not advertise configuration");
|
|
259
|
+
|
|
219
260
|
await active.connection.configure(params.configId, params.value);
|
|
220
261
|
this.save(state);
|
|
221
262
|
|
|
222
263
|
return structuredClone(state);
|
|
223
264
|
}
|
|
224
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
|
+
|
|
225
433
|
private enqueueComment(
|
|
226
434
|
state: ThreadAgentState,
|
|
227
435
|
thread: Thread,
|
|
@@ -235,6 +443,7 @@ export class ThreadAgentManager {
|
|
|
235
443
|
|
|
236
444
|
if (!input || state.submissions?.some((submission) => submission.commentId === commentId))
|
|
237
445
|
throw new Error("Shared agent comment is unavailable or already sent");
|
|
446
|
+
|
|
238
447
|
enqueue({
|
|
239
448
|
commentId,
|
|
240
449
|
prompt: frozen?.text ?? input.body,
|
|
@@ -242,6 +451,7 @@ export class ThreadAgentManager {
|
|
|
242
451
|
messageId: comment?.messageId,
|
|
243
452
|
context: frozen?.discussion ?? commentDiscussion(state, thread, commentId),
|
|
244
453
|
});
|
|
454
|
+
|
|
245
455
|
if (comment) comment.sent = true;
|
|
246
456
|
}
|
|
247
457
|
|
|
@@ -261,8 +471,10 @@ export class ThreadAgentManager {
|
|
|
261
471
|
128
|
|
262
472
|
)
|
|
263
473
|
throw new Error("Thread agent prototype reached its 128-message limit");
|
|
474
|
+
|
|
264
475
|
if (Buffer.byteLength(input.prompt) > 320_000 || Buffer.byteLength(input.quote ?? "") > 320_000)
|
|
265
476
|
throw new Error("Thread agent input exceeds 320 KiB");
|
|
477
|
+
|
|
266
478
|
const used = state.messages.reduce(
|
|
267
479
|
(total, message) => total + Buffer.byteLength(message.text),
|
|
268
480
|
0,
|
|
@@ -270,6 +482,7 @@ export class ThreadAgentManager {
|
|
|
270
482
|
|
|
271
483
|
if (used + Buffer.byteLength(input.prompt) > 2 * 1024 * 1024)
|
|
272
484
|
throw new Error("Thread agent input exceeds transcript limit");
|
|
485
|
+
|
|
273
486
|
const submission: AgentSubmission = {
|
|
274
487
|
...input,
|
|
275
488
|
id: operationId ?? randomUUID(),
|
|
@@ -297,9 +510,17 @@ export class ThreadAgentManager {
|
|
|
297
510
|
this.retiring.has(state.threadId)
|
|
298
511
|
)
|
|
299
512
|
return;
|
|
513
|
+
|
|
514
|
+
if (state.handoff) {
|
|
515
|
+
void this.performHandoff(state);
|
|
516
|
+
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
300
520
|
const submission = state.submissions?.find((entry) => entry.status === "queued");
|
|
301
521
|
|
|
302
522
|
if (!submission) return;
|
|
523
|
+
|
|
303
524
|
const before = structuredClone(state);
|
|
304
525
|
|
|
305
526
|
submission.status = stepAgentSubmission(submission.status, "start", true);
|
|
@@ -327,14 +548,17 @@ Discussion context: ${submission.context ?? submission.messageId ?? ""}
|
|
|
327
548
|
Input: ${submission.prompt}`;
|
|
328
549
|
|
|
329
550
|
try {
|
|
330
|
-
submission.harnessPrompt =
|
|
551
|
+
submission.harnessPrompt = state.continuation
|
|
552
|
+
? harnessContinuationPrompt(state.continuation, prompt)
|
|
553
|
+
: prompt;
|
|
554
|
+
state.continuation = undefined;
|
|
331
555
|
this.save(state);
|
|
332
556
|
} catch (error) {
|
|
333
557
|
// Acceptance is already durable; a retry can resume the queue before any harness request.
|
|
334
558
|
restoreAgentState(state, before);
|
|
335
559
|
throw error;
|
|
336
560
|
}
|
|
337
|
-
void this.run(state, thread, submission.id, revision,
|
|
561
|
+
void this.run(state, thread, submission.id, revision, submission.harnessPrompt!, submission);
|
|
338
562
|
}
|
|
339
563
|
|
|
340
564
|
/** Attach quote-primary feedback only to a finalized agent message. */
|
|
@@ -344,8 +568,10 @@ Input: ${submission.prompt}`;
|
|
|
344
568
|
|
|
345
569
|
if (!message || !message.complete)
|
|
346
570
|
throw new Error("Thread agent comment requires a completed answer");
|
|
571
|
+
|
|
347
572
|
if (!resolveAnchor(params.comment.anchor, parseBlocks(message.text)))
|
|
348
573
|
throw new Error("Thread agent comment quote does not resolve");
|
|
574
|
+
|
|
349
575
|
const index = state.comments.findIndex((comment) => comment.id === params.comment.id);
|
|
350
576
|
const previous = state.comments[index];
|
|
351
577
|
const root = params.comment.replyTo
|
|
@@ -357,19 +583,24 @@ Input: ${submission.prompt}`;
|
|
|
357
583
|
(!root || root.messageId !== message.id || root.id === params.comment.id)
|
|
358
584
|
)
|
|
359
585
|
throw new Error("Thread agent reply requires a comment on the same answer");
|
|
586
|
+
|
|
360
587
|
if (
|
|
361
588
|
this.isReadOnly(params.id, params.comment.id) ||
|
|
362
589
|
(previous && (previous.sent || previous.messageId !== params.comment.messageId))
|
|
363
590
|
)
|
|
364
591
|
throw new Error("Thread agent delivered comments are immutable");
|
|
592
|
+
|
|
365
593
|
if (Buffer.byteLength(JSON.stringify(params.comment)) > 32_768)
|
|
366
594
|
throw new Error("Thread agent comment exceeds 32 KiB");
|
|
595
|
+
|
|
367
596
|
if (index >= 0) state.comments[index] = { ...params.comment, sent: false };
|
|
368
597
|
else {
|
|
369
598
|
if (state.comments.length >= 128)
|
|
370
599
|
throw new Error("Thread agent prototype reached its comment limit");
|
|
600
|
+
|
|
371
601
|
state.comments.push({ ...params.comment, sent: false });
|
|
372
602
|
}
|
|
603
|
+
|
|
373
604
|
this.save(state);
|
|
374
605
|
|
|
375
606
|
return structuredClone(state);
|
|
@@ -381,10 +612,12 @@ Input: ${submission.prompt}`;
|
|
|
381
612
|
const comment = agentCommentRoot(state, commentId);
|
|
382
613
|
|
|
383
614
|
if (!comment) throw new Error("Thread agent reply comment does not exist");
|
|
615
|
+
|
|
384
616
|
const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
|
|
385
617
|
|
|
386
618
|
if (state.comments.length >= 128)
|
|
387
619
|
throw new Error("Thread agent prototype reached its comment limit");
|
|
620
|
+
|
|
388
621
|
state.comments.push({
|
|
389
622
|
...root,
|
|
390
623
|
id: randomUUID(),
|
|
@@ -408,6 +641,7 @@ Input: ${submission.prompt}`;
|
|
|
408
641
|
const submission = state.submissions?.find((entry) => entry.status === "running");
|
|
409
642
|
|
|
410
643
|
if (submission) submission.cancelled = true;
|
|
644
|
+
|
|
411
645
|
for (const tool of state.tools) {
|
|
412
646
|
if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
|
|
413
647
|
}
|
|
@@ -431,8 +665,10 @@ Input: ${submission.prompt}`;
|
|
|
431
665
|
) {
|
|
432
666
|
throw new Error("Thread agent permission request is no longer pending");
|
|
433
667
|
}
|
|
668
|
+
|
|
434
669
|
if (!state.phase.permission.options.some((option) => option.optionId === params.optionId))
|
|
435
670
|
throw new Error("Thread agent permission option was not advertised");
|
|
671
|
+
|
|
436
672
|
active.connection.permission(params.requestId, params.optionId);
|
|
437
673
|
state.phase = { kind: "running" };
|
|
438
674
|
this.save(state);
|
|
@@ -467,7 +703,9 @@ Input: ${submission.prompt}`;
|
|
|
467
703
|
this.active.delete(id);
|
|
468
704
|
active?.connection.cancel();
|
|
469
705
|
await active?.connection.close();
|
|
706
|
+
|
|
470
707
|
if (state.harness?.sessionId) await this.adapter(state).remove?.(state.harness.sessionId);
|
|
708
|
+
|
|
471
709
|
rmSync(this.path(id), { force: true });
|
|
472
710
|
rmSync(this.path(id) + ".tmp", { force: true });
|
|
473
711
|
rmSync(join(this.directory, `${encodeURIComponent(id)}.diagnostics.ndjson`), { force: true });
|
|
@@ -476,17 +714,39 @@ Input: ${submission.prompt}`;
|
|
|
476
714
|
}
|
|
477
715
|
}
|
|
478
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
|
+
|
|
479
734
|
private adapter(state: ThreadAgentState): AgentHarnessAdapter {
|
|
480
|
-
const adapter =
|
|
481
|
-
(state.harness && this.options.adapters?.[state.harness.id]) ?? this.options.adapter;
|
|
735
|
+
const adapter = this.availableAdapter(state);
|
|
482
736
|
|
|
483
|
-
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
|
+
);
|
|
484
743
|
|
|
485
744
|
return adapter;
|
|
486
745
|
}
|
|
487
746
|
|
|
488
747
|
private assertReadable(id: string): void {
|
|
489
748
|
if (this.deleting.has(id)) throw new Error("Thread agent is being deleted");
|
|
749
|
+
|
|
490
750
|
this.options.getThread(id);
|
|
491
751
|
}
|
|
492
752
|
|
|
@@ -502,11 +762,14 @@ Input: ${submission.prompt}`;
|
|
|
502
762
|
|
|
503
763
|
private save(state: ThreadAgentState): void {
|
|
504
764
|
settlePromptOperations(state);
|
|
765
|
+
|
|
505
766
|
if (this.states.get(state.threadId) !== state) return;
|
|
767
|
+
|
|
506
768
|
const data = JSON.stringify(state);
|
|
507
769
|
|
|
508
770
|
if (Buffer.byteLength(data) > 8 * 1024 * 1024)
|
|
509
771
|
throw new Error("Thread agent transcript exceeds 8 MiB");
|
|
772
|
+
|
|
510
773
|
mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
511
774
|
writeFileSync(this.path(state.threadId) + ".tmp", data, { mode: 0o600 });
|
|
512
775
|
renameSync(this.path(state.threadId) + ".tmp", this.path(state.threadId));
|
|
@@ -519,38 +782,45 @@ Input: ${submission.prompt}`;
|
|
|
519
782
|
turnId: string,
|
|
520
783
|
revision: number,
|
|
521
784
|
prompt: string,
|
|
522
|
-
comments: AgentComment[],
|
|
523
785
|
submission?: AgentSubmission,
|
|
524
786
|
): Promise<void> {
|
|
525
787
|
let active = this.active.get(state.threadId);
|
|
526
|
-
const messageStart = state.messages.length;
|
|
527
788
|
|
|
528
789
|
try {
|
|
529
790
|
active = await this.connect(state, thread, turnId, revision);
|
|
791
|
+
|
|
530
792
|
if (this.states.get(state.threadId) !== state) return;
|
|
793
|
+
|
|
531
794
|
active.turnId = turnId;
|
|
532
795
|
active.revision = revision;
|
|
796
|
+
active.attemptId = submission?.attemptId;
|
|
797
|
+
|
|
533
798
|
// Cancellation during initialization must not submit a new model request.
|
|
534
799
|
if (active.turn.kind === "idle") {
|
|
535
800
|
if (submission) submission.status = "failed";
|
|
801
|
+
|
|
536
802
|
state.phase = { kind: "idle" };
|
|
537
803
|
this.save(state);
|
|
538
804
|
this.drain(state);
|
|
539
805
|
|
|
540
806
|
return;
|
|
541
807
|
}
|
|
542
|
-
|
|
808
|
+
|
|
809
|
+
const result = await active.connection.prompt(prompt, submission?.attemptId ?? turnId);
|
|
543
810
|
|
|
544
811
|
if (this.states.get(state.threadId) !== state) return;
|
|
545
|
-
|
|
812
|
+
|
|
813
|
+
finalizeAgentMessages(state, active, result.outcome);
|
|
814
|
+
|
|
546
815
|
if (submission && result.outcome === "cancelled") submission.cancelled = true;
|
|
816
|
+
|
|
547
817
|
if (submission)
|
|
548
818
|
submission.status = stepAgentSubmission(
|
|
549
819
|
submission.status,
|
|
550
820
|
result.outcome === "completed" ? "complete" : "fail",
|
|
551
821
|
true,
|
|
552
822
|
);
|
|
553
|
-
|
|
823
|
+
|
|
554
824
|
for (const tool of state.tools) {
|
|
555
825
|
if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
|
|
556
826
|
}
|
|
@@ -560,11 +830,13 @@ Input: ${submission.prompt}`;
|
|
|
560
830
|
this.drain(state);
|
|
561
831
|
} catch (error) {
|
|
562
832
|
if (submission) submission.status = "failed";
|
|
833
|
+
|
|
563
834
|
state.phase = {
|
|
564
835
|
kind: "failed",
|
|
565
836
|
error: error instanceof Error ? error.message : "Thread agent request failed",
|
|
566
837
|
};
|
|
567
838
|
await this.retire(state.threadId, active);
|
|
839
|
+
|
|
568
840
|
if (!this.disposed && this.states.get(state.threadId) === state) {
|
|
569
841
|
this.save(state);
|
|
570
842
|
this.drain(state);
|
|
@@ -576,6 +848,7 @@ Input: ${submission.prompt}`;
|
|
|
576
848
|
const existing = this.retiring.get(id);
|
|
577
849
|
|
|
578
850
|
if (existing) return existing;
|
|
851
|
+
|
|
579
852
|
this.active.delete(id);
|
|
580
853
|
const closing = Promise.resolve()
|
|
581
854
|
.then(() => active?.connection.close())
|
|
@@ -598,10 +871,13 @@ Input: ${submission.prompt}`;
|
|
|
598
871
|
const retiring = this.retiring.get(state.threadId);
|
|
599
872
|
|
|
600
873
|
if (retiring) await retiring;
|
|
874
|
+
|
|
601
875
|
if (this.disposed) throw new Error("Thread agent is shutting down");
|
|
876
|
+
|
|
602
877
|
const pending = this.initializing.get(state.threadId);
|
|
603
878
|
|
|
604
879
|
if (pending) return pending;
|
|
880
|
+
|
|
605
881
|
const promise = this.startConnection(state, thread, turnId, revision);
|
|
606
882
|
|
|
607
883
|
this.initializing.set(state.threadId, promise);
|
|
@@ -612,6 +888,22 @@ Input: ${submission.prompt}`;
|
|
|
612
888
|
}
|
|
613
889
|
}
|
|
614
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
|
+
|
|
615
907
|
private async startConnection(
|
|
616
908
|
state: ThreadAgentState,
|
|
617
909
|
thread: Thread,
|
|
@@ -625,11 +917,13 @@ Input: ${submission.prompt}`;
|
|
|
625
917
|
const connection = adapter.connect({
|
|
626
918
|
cwd: thread.artifact.meta.cwd ?? thread.workspace.repoRoot,
|
|
627
919
|
sessionId: state.harness?.sessionId,
|
|
628
|
-
tools: this.
|
|
920
|
+
tools: this.handoffTools(state, thread),
|
|
629
921
|
onEvent: (event) => this.receive(state, event),
|
|
630
922
|
onExit: (error) => {
|
|
631
923
|
if (this.active.get(state.threadId)?.connection !== connection) return;
|
|
924
|
+
|
|
632
925
|
this.active.delete(state.threadId);
|
|
926
|
+
|
|
633
927
|
if (!this.disposed) {
|
|
634
928
|
state.phase = { kind: "failed", error: error.message };
|
|
635
929
|
this.save(state);
|
|
@@ -642,6 +936,7 @@ Input: ${submission.prompt}`;
|
|
|
642
936
|
const sessionId = await connection.start();
|
|
643
937
|
|
|
644
938
|
if (this.states.get(state.threadId) !== state) throw new Error("Thread agent was removed");
|
|
939
|
+
|
|
645
940
|
state.harness = {
|
|
646
941
|
id: adapter.id,
|
|
647
942
|
label: adapter.label,
|
|
@@ -657,7 +952,20 @@ Input: ${submission.prompt}`;
|
|
|
657
952
|
}
|
|
658
953
|
|
|
659
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
|
+
|
|
660
967
|
if (this.states.get(state.threadId) !== state) return;
|
|
968
|
+
|
|
661
969
|
const routed = routeHarnessOutput(event);
|
|
662
970
|
|
|
663
971
|
if (routed.destination === "diagnostics") {
|
|
@@ -665,27 +973,33 @@ Input: ${submission.prompt}`;
|
|
|
665
973
|
|
|
666
974
|
return;
|
|
667
975
|
}
|
|
976
|
+
|
|
668
977
|
event = routed.event;
|
|
978
|
+
|
|
669
979
|
if (event.kind === "config") {
|
|
670
980
|
state.configOptions = event.options;
|
|
671
981
|
this.save(state);
|
|
672
982
|
|
|
673
983
|
return;
|
|
674
984
|
}
|
|
985
|
+
|
|
675
986
|
const active = this.active.get(state.threadId);
|
|
676
987
|
|
|
677
988
|
if (!active || active.turn.kind === "starting") return;
|
|
989
|
+
|
|
678
990
|
if (event.kind === "permission") {
|
|
679
991
|
if (agentTurnCancelled(active.turn)) {
|
|
680
992
|
active.connection.permission(event.permission.id);
|
|
681
993
|
|
|
682
994
|
return;
|
|
683
995
|
}
|
|
996
|
+
|
|
684
997
|
state.phase = { kind: "permission", permission: event.permission };
|
|
685
998
|
this.save(state);
|
|
686
999
|
|
|
687
1000
|
return;
|
|
688
1001
|
}
|
|
1002
|
+
|
|
689
1003
|
this.applyUpdate(state, active, event);
|
|
690
1004
|
}
|
|
691
1005
|
|
|
@@ -695,7 +1009,9 @@ Input: ${submission.prompt}`;
|
|
|
695
1009
|
update: Extract<AgentHarnessEvent, { kind: "message" | "tool" }>,
|
|
696
1010
|
): void {
|
|
697
1011
|
if (update.kind === "message") {
|
|
698
|
-
const messageId =
|
|
1012
|
+
const messageId = active.attemptId
|
|
1013
|
+
? `${active.attemptId}:${update.id ?? "answer"}`
|
|
1014
|
+
: (update.id ?? `answer-${active.turnId}`);
|
|
699
1015
|
let message = state.messages.find((message) => message.id === messageId);
|
|
700
1016
|
|
|
701
1017
|
if (!message) {
|
|
@@ -709,7 +1025,9 @@ Input: ${submission.prompt}`;
|
|
|
709
1025
|
};
|
|
710
1026
|
state.messages.push(message);
|
|
711
1027
|
}
|
|
1028
|
+
|
|
712
1029
|
if (message.complete) return;
|
|
1030
|
+
|
|
713
1031
|
const used = state.messages.reduce(
|
|
714
1032
|
(total, entry) => total + Buffer.byteLength(entry.text),
|
|
715
1033
|
0,
|
|
@@ -736,6 +1054,7 @@ Input: ${submission.prompt}`;
|
|
|
736
1054
|
if (!tool) {
|
|
737
1055
|
if (state.tools.length >= 128)
|
|
738
1056
|
throw new Error("Thread agent reached its tool activity limit");
|
|
1057
|
+
|
|
739
1058
|
tool = {
|
|
740
1059
|
id: update.id,
|
|
741
1060
|
turnId: active.turnId,
|
|
@@ -747,16 +1066,22 @@ Input: ${submission.prompt}`;
|
|
|
747
1066
|
};
|
|
748
1067
|
state.tools.push(tool);
|
|
749
1068
|
}
|
|
1069
|
+
|
|
750
1070
|
if (update.title) tool.title = update.title.slice(0, 256);
|
|
1071
|
+
|
|
751
1072
|
if (update.toolKind) tool.kind = update.toolKind;
|
|
1073
|
+
|
|
752
1074
|
if (update.status)
|
|
753
1075
|
tool.status = agentTurnCancelled(active.turn) ? "cancelled" : update.status;
|
|
1076
|
+
|
|
754
1077
|
if (update.locations)
|
|
755
1078
|
tool.locations = update.locations
|
|
756
1079
|
.slice(0, 8)
|
|
757
1080
|
.map((location) => ({ ...location, path: location.path.slice(0, 512) }));
|
|
1081
|
+
|
|
758
1082
|
if (update.output !== undefined) tool.output = update.output.slice(0, 8192);
|
|
759
1083
|
}
|
|
1084
|
+
|
|
760
1085
|
this.save(state);
|
|
761
1086
|
}
|
|
762
1087
|
}
|
|
@@ -764,11 +1089,18 @@ Input: ${submission.prompt}`;
|
|
|
764
1089
|
/** Only this turn can finalize its answers; stopped and historical partials stay incomplete. */
|
|
765
1090
|
function finalizeAgentMessages(
|
|
766
1091
|
state: ThreadAgentState,
|
|
767
|
-
|
|
1092
|
+
active: ActiveAgent,
|
|
768
1093
|
outcome: AgentHarnessResult["outcome"],
|
|
769
1094
|
): void {
|
|
770
1095
|
if (outcome !== "completed") return;
|
|
771
|
-
|
|
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
|
+
}
|
|
772
1104
|
}
|
|
773
1105
|
|
|
774
1106
|
function isPendingAgentInput(
|
|
@@ -785,8 +1117,11 @@ function isPendingAgentInput(
|
|
|
785
1117
|
|
|
786
1118
|
function restoreAgentState(state: ThreadAgentState, before: ThreadAgentState): void {
|
|
787
1119
|
if (before.submissions === undefined) delete state.submissions;
|
|
1120
|
+
|
|
788
1121
|
if (before.promptOperations === undefined) delete state.promptOperations;
|
|
1122
|
+
|
|
789
1123
|
if (before.harness === undefined) delete state.harness;
|
|
1124
|
+
|
|
790
1125
|
Object.assign(state, before);
|
|
791
1126
|
}
|
|
792
1127
|
|
|
@@ -824,6 +1159,7 @@ function commentDiscussion(state: ThreadAgentState, thread: Thread, commentId: s
|
|
|
824
1159
|
.map((entry) => ({ id: entry.id, body: entry.body })),
|
|
825
1160
|
});
|
|
826
1161
|
}
|
|
1162
|
+
|
|
827
1163
|
const comment = agentCommentRoot(state, commentId)!;
|
|
828
1164
|
const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
|
|
829
1165
|
|