@cueloop/daemon 0.1.0-alpha.98 → 0.1.0-alpha.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/SDK.md +102 -0
- package/package.json +12 -8
- package/src/api.ts +19 -1
- package/src/capabilities.ts +1 -0
- package/src/client-errors.ts +24 -0
- package/src/client-reliability.test.ts +189 -0
- package/src/client.ts +139 -30
- package/src/daemon-sdk-effect.ts +113 -0
- package/src/daemon-sdk.test.ts +306 -0
- package/src/daemon-sdk.ts +397 -0
- package/src/examples/daemon-sdk.ts +55 -0
- package/src/harness-delivery.test.ts +1 -0
- package/src/operation-receipts.test.ts +111 -0
- package/src/operation-receipts.ts +91 -0
- package/src/pr-poller.ts +1 -0
- package/src/sdk-identities.ts +35 -0
- package/src/server.ts +32 -2
- package/src/share-blob.ts +3 -1
- package/src/store.jsonl.test.ts +14 -0
- package/src/store.ts +11 -2
- package/src/thread-agent-validation.ts +11 -0
- package/src/thread-agent.test.ts +105 -0
- package/src/thread-agent.ts +53 -5
- package/src/thread-review.ts +1 -0
- package/src/thread-subscription.test.ts +174 -0
- package/src/thread-subscription.ts +127 -0
- package/src/validate.test.ts +1 -0
- package/src/validate.ts +10 -0
- package/src/vcs-source.ts +1 -0
- package/src/working-tree.ts +2 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
|
|
3
|
+
const ThreadIdSchema = v.pipe(v.string(), v.minLength(1), v.maxLength(128), v.brand("SdkThreadId"));
|
|
4
|
+
const OperationIdSchema = v.pipe(
|
|
5
|
+
v.string(),
|
|
6
|
+
v.minLength(1),
|
|
7
|
+
v.maxLength(128),
|
|
8
|
+
v.brand("SdkOperationId"),
|
|
9
|
+
);
|
|
10
|
+
const SubmissionIdSchema = v.pipe(
|
|
11
|
+
v.string(),
|
|
12
|
+
v.minLength(1),
|
|
13
|
+
v.maxLength(128),
|
|
14
|
+
v.brand("SdkSubmissionId"),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/** Thread, operation and submission IDs share a wire representation, not a domain type. */
|
|
18
|
+
export type SdkThreadId = v.InferOutput<typeof ThreadIdSchema>;
|
|
19
|
+
/** Operation IDs are caller-owned and persist across explicit retries. */
|
|
20
|
+
export type SdkOperationId = v.InferOutput<typeof OperationIdSchema>;
|
|
21
|
+
/** Submission IDs are minted by the daemon after accepting agent input. */
|
|
22
|
+
export type SdkSubmissionId = v.InferOutput<typeof SubmissionIdSchema>;
|
|
23
|
+
|
|
24
|
+
/** External Thread identities must be nonempty and at most 128 characters. */
|
|
25
|
+
export function createSdkThreadId(value: string): SdkThreadId {
|
|
26
|
+
return v.parse(ThreadIdSchema, value);
|
|
27
|
+
}
|
|
28
|
+
/** Caller operation identities are stable across retries and at most 128 characters. */
|
|
29
|
+
export function createSdkOperationId(value: string): SdkOperationId {
|
|
30
|
+
return v.parse(OperationIdSchema, value);
|
|
31
|
+
}
|
|
32
|
+
/** Submission identities are daemon-minted; parsing grants no capability. */
|
|
33
|
+
export function createSdkSubmissionId(value: string): SdkSubmissionId {
|
|
34
|
+
return v.parse(SubmissionIdSchema, value);
|
|
35
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -353,6 +353,7 @@ export class DaemonServer {
|
|
|
353
353
|
private async callAgentTool(threadId: string, name: string, input: string): Promise<string> {
|
|
354
354
|
const args = parseAgentToolInput(name, input);
|
|
355
355
|
const origin = this.core.sessionGet(threadId);
|
|
356
|
+
|
|
356
357
|
assertAgentToolScope(origin, args);
|
|
357
358
|
if (args.kind === "api" && args.method === "session.list") {
|
|
358
359
|
const { filter } = parseParams("session.list", args.params);
|
|
@@ -362,8 +363,8 @@ export class DaemonServer {
|
|
|
362
363
|
if (args.kind === "reply") {
|
|
363
364
|
const thread = origin;
|
|
364
365
|
const comment = thread.annotations.find((entry) => entry.id === args.commentId);
|
|
365
|
-
|
|
366
|
-
|
|
366
|
+
|
|
367
|
+
if (!comment) return JSON.stringify(this.replyAgentComment(args));
|
|
367
368
|
const root =
|
|
368
369
|
thread.annotations.find((entry) => entry.id === (comment.replyTo ?? comment.id)) ?? comment;
|
|
369
370
|
|
|
@@ -384,6 +385,7 @@ export class DaemonServer {
|
|
|
384
385
|
);
|
|
385
386
|
}
|
|
386
387
|
const method = args.method;
|
|
388
|
+
|
|
387
389
|
if (!isKnownMethod(method)) throw new Error("Thread agent tool API method is unavailable");
|
|
388
390
|
|
|
389
391
|
return JSON.stringify(
|
|
@@ -394,35 +396,62 @@ export class DaemonServer {
|
|
|
394
396
|
);
|
|
395
397
|
}
|
|
396
398
|
|
|
399
|
+
private replyAgentComment(params: {
|
|
400
|
+
id: string;
|
|
401
|
+
commentId: string;
|
|
402
|
+
body: string;
|
|
403
|
+
}): ReturnType<ThreadAgentManager["reply"]> {
|
|
404
|
+
this.threadAgent.assertEnabled(params.id);
|
|
405
|
+
|
|
406
|
+
return this.threadAgent.reply(params.id, params.commentId, params.body);
|
|
407
|
+
}
|
|
408
|
+
|
|
397
409
|
private readonly handlers: Record<MethodName, MethodHandler> = {
|
|
398
410
|
"agent.configure": (_connection, request) => {
|
|
399
411
|
const params = parseParams("agent.configure", request.params);
|
|
412
|
+
|
|
400
413
|
this.threadAgent.assertEnabled(params.id);
|
|
414
|
+
|
|
401
415
|
return this.threadAgent.configure(params);
|
|
402
416
|
},
|
|
403
417
|
"agent.get": (_connection, request) => {
|
|
404
418
|
const { id } = parseParams("agent.get", request.params);
|
|
419
|
+
|
|
405
420
|
this.threadAgent.assertEnabled(id);
|
|
421
|
+
|
|
406
422
|
return this.threadAgent.get(id);
|
|
407
423
|
},
|
|
408
424
|
"agent.prompt": (_connection, request) => {
|
|
409
425
|
const params = parseParams("agent.prompt", request.params);
|
|
426
|
+
|
|
410
427
|
this.threadAgent.assertEnabled(params.id);
|
|
428
|
+
|
|
411
429
|
return this.threadAgent.prompt(params);
|
|
412
430
|
},
|
|
413
431
|
"agent.cancel": (_connection, request) => {
|
|
414
432
|
const { id } = parseParams("agent.cancel", request.params);
|
|
433
|
+
|
|
415
434
|
this.threadAgent.assertEnabled(id);
|
|
435
|
+
|
|
416
436
|
return this.threadAgent.cancel(id);
|
|
417
437
|
},
|
|
438
|
+
"agent.reply": (_connection, request) => {
|
|
439
|
+
const params = parseParams("agent.reply", request.params);
|
|
440
|
+
|
|
441
|
+
return this.replyAgentComment(params);
|
|
442
|
+
},
|
|
418
443
|
"agent.comment": (_connection, request) => {
|
|
419
444
|
const params = parseParams("agent.comment", request.params);
|
|
445
|
+
|
|
420
446
|
this.threadAgent.assertEnabled(params.id);
|
|
447
|
+
|
|
421
448
|
return this.threadAgent.comment(params);
|
|
422
449
|
},
|
|
423
450
|
"agent.permission": (_connection, request) => {
|
|
424
451
|
const params = parseParams("agent.permission", request.params);
|
|
452
|
+
|
|
425
453
|
this.threadAgent.assertEnabled(params.id);
|
|
454
|
+
|
|
426
455
|
return this.threadAgent.permission(params);
|
|
427
456
|
},
|
|
428
457
|
"daemon.ping": () => ({ pid: process.pid, version: this.version }),
|
|
@@ -645,6 +674,7 @@ export class DaemonServer {
|
|
|
645
674
|
params.outcome,
|
|
646
675
|
params.summary,
|
|
647
676
|
params.actionBodies,
|
|
677
|
+
params.operationId,
|
|
648
678
|
);
|
|
649
679
|
},
|
|
650
680
|
"harness.bind": (_connection, request) => {
|
package/src/share-blob.ts
CHANGED
|
@@ -25,7 +25,9 @@ export const DEFAULT_SHARE_PORT = 22;
|
|
|
25
25
|
|
|
26
26
|
/** Serialise + compress a session for upload. */
|
|
27
27
|
export function packSessionBlob(session: Thread): Buffer {
|
|
28
|
-
|
|
28
|
+
const { messageOperations: _privateReceipts, ...shared } = session;
|
|
29
|
+
|
|
30
|
+
return gzipSync(Buffer.from(JSON.stringify(shared), "utf8"));
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/** Decompress + validate an uploaded blob, or throw a precise reason. */
|
package/src/store.jsonl.test.ts
CHANGED
|
@@ -28,6 +28,7 @@ describe("JSONL thread store", () => {
|
|
|
28
28
|
test("a thread is written under a per-project bucket keyed by root commit", () => {
|
|
29
29
|
const home = tmpHome();
|
|
30
30
|
const store = new ThreadStore(home);
|
|
31
|
+
|
|
31
32
|
store.recover();
|
|
32
33
|
store.upsert(
|
|
33
34
|
createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", {
|
|
@@ -36,6 +37,7 @@ describe("JSONL thread store", () => {
|
|
|
36
37
|
);
|
|
37
38
|
|
|
38
39
|
const bucket = threadBucket("abcdef0123456789", home);
|
|
40
|
+
|
|
39
41
|
expect(readdirSync(bucket)).toContain("ses_a.jsonl");
|
|
40
42
|
});
|
|
41
43
|
|
|
@@ -52,6 +54,7 @@ describe("JSONL thread store", () => {
|
|
|
52
54
|
test("a repo-less thread lands in the standalone bucket", () => {
|
|
53
55
|
const home = tmpHome();
|
|
54
56
|
const store = new ThreadStore(home);
|
|
57
|
+
|
|
55
58
|
store.recover();
|
|
56
59
|
store.upsert(createTestThreadRecord("ses_s", "2026-09-01T00:00:00.000Z"));
|
|
57
60
|
|
|
@@ -60,8 +63,10 @@ describe("JSONL thread store", () => {
|
|
|
60
63
|
|
|
61
64
|
test("boot migrates the old whole-record file and parks the original", () => {
|
|
62
65
|
const home = tmpHome();
|
|
66
|
+
|
|
63
67
|
mkdirSync(sessionsDir(home), { recursive: true });
|
|
64
68
|
const legacy = createTestThreadRecord("ses_old", "2026-09-01T00:00:00.000Z");
|
|
69
|
+
|
|
65
70
|
writeFileSync(join(sessionsDir(home), "ses_old.json"), JSON.stringify(legacy));
|
|
66
71
|
|
|
67
72
|
const store = new ThreadStore(home);
|
|
@@ -80,6 +85,7 @@ describe("JSONL thread store", () => {
|
|
|
80
85
|
const home = tmpHome();
|
|
81
86
|
// a newer JSONL already holds this thread (e.g. written after a rollback)
|
|
82
87
|
const store = new ThreadStore(home);
|
|
88
|
+
|
|
83
89
|
store.recover();
|
|
84
90
|
store.upsert(
|
|
85
91
|
createTestThreadRecord("ses_x", "2026-09-01T00:00:00.000Z", {
|
|
@@ -99,6 +105,7 @@ describe("JSONL thread store", () => {
|
|
|
99
105
|
);
|
|
100
106
|
|
|
101
107
|
const reopened = new ThreadStore(home);
|
|
108
|
+
|
|
102
109
|
reopened.recover();
|
|
103
110
|
// the newer JSONL wins; the legacy snapshot is parked, not applied
|
|
104
111
|
expect(reopened.get("ses_x")?.workspace.branch).toBe("newer");
|
|
@@ -108,6 +115,7 @@ describe("JSONL thread store", () => {
|
|
|
108
115
|
test("a long log compacts to a single line, and the record survives it", () => {
|
|
109
116
|
const home = tmpHome();
|
|
110
117
|
const store = new ThreadStore(home);
|
|
118
|
+
|
|
111
119
|
store.recover();
|
|
112
120
|
// more upserts than the compaction threshold
|
|
113
121
|
for (let iteration = 0; iteration < 60; iteration++) {
|
|
@@ -123,9 +131,11 @@ describe("JSONL thread store", () => {
|
|
|
123
131
|
const lines = readFileSync(file, "utf8")
|
|
124
132
|
.split("\n")
|
|
125
133
|
.filter((l) => l.trim());
|
|
134
|
+
|
|
126
135
|
expect(lines.length).toBeLessThan(60);
|
|
127
136
|
// the latest snapshot survives the compaction and a restart
|
|
128
137
|
const reopened = new ThreadStore(home);
|
|
138
|
+
|
|
129
139
|
reopened.recover();
|
|
130
140
|
expect(reopened.get("ses_a")?.workspace.branch).toBe("b59");
|
|
131
141
|
});
|
|
@@ -133,15 +143,18 @@ describe("JSONL thread store", () => {
|
|
|
133
143
|
test("a crash's torn trailing line is skipped; the last valid snapshot wins", () => {
|
|
134
144
|
const home = tmpHome();
|
|
135
145
|
const store = new ThreadStore(home);
|
|
146
|
+
|
|
136
147
|
store.recover();
|
|
137
148
|
store.upsert(createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z"));
|
|
138
149
|
|
|
139
150
|
// simulate a crash mid-append: a half-written line at the end
|
|
140
151
|
const file = join(threadBucket(undefined, home), "ses_a.jsonl");
|
|
152
|
+
|
|
141
153
|
appendFileSync(file, '{"id":"ses_a","half');
|
|
142
154
|
|
|
143
155
|
const reopened = new ThreadStore(home);
|
|
144
156
|
const report = reopened.recover();
|
|
157
|
+
|
|
145
158
|
expect(report.recovered).toEqual(["ses_a"]);
|
|
146
159
|
expect(reopened.get("ses_a")?.id).toBe("ses_a");
|
|
147
160
|
|
|
@@ -153,6 +166,7 @@ describe("JSONL thread store", () => {
|
|
|
153
166
|
}),
|
|
154
167
|
);
|
|
155
168
|
const restarted = new ThreadStore(home);
|
|
169
|
+
|
|
156
170
|
restarted.recover();
|
|
157
171
|
expect(restarted.get("ses_a")?.workspace.branch).toBe("after-tear");
|
|
158
172
|
});
|
package/src/store.ts
CHANGED
|
@@ -94,6 +94,7 @@ export class ThreadStore implements ThreadRepository {
|
|
|
94
94
|
for (const file of readdirSync(bucketPath)) {
|
|
95
95
|
if (!file.endsWith(".jsonl")) continue;
|
|
96
96
|
const filePath = join(bucketPath, file);
|
|
97
|
+
|
|
97
98
|
try {
|
|
98
99
|
const { session, lines, torn } = readThread(filePath);
|
|
99
100
|
|
|
@@ -181,19 +182,25 @@ export class ThreadStore implements ThreadRepository {
|
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
upsert(session: Thread): void {
|
|
184
|
-
this.sessions.set(session.id, session);
|
|
185
185
|
const filePath = this.files.get(session.id);
|
|
186
186
|
const lines = this.lineCounts.get(session.id) ?? 0;
|
|
187
187
|
|
|
188
188
|
// the first write, an over-long log, and a resolved (now immutable) thread all compact to one
|
|
189
189
|
// line; every other mutation is a cheap append that a torn tail on crash simply drops on read
|
|
190
|
-
if (
|
|
190
|
+
if (
|
|
191
|
+
filePath === undefined ||
|
|
192
|
+
lines >= COMPACT_THRESHOLD ||
|
|
193
|
+
session.status === "resolved" ||
|
|
194
|
+
session.messageOperations?.length
|
|
195
|
+
) {
|
|
191
196
|
this.writeWhole(session);
|
|
197
|
+
this.sessions.set(session.id, session);
|
|
192
198
|
|
|
193
199
|
return;
|
|
194
200
|
}
|
|
195
201
|
appendFileSync(filePath, JSON.stringify(session) + "\n");
|
|
196
202
|
this.lineCounts.set(session.id, lines + 1);
|
|
203
|
+
this.sessions.set(session.id, session);
|
|
197
204
|
}
|
|
198
205
|
|
|
199
206
|
delete(id: string): boolean {
|
|
@@ -290,9 +297,11 @@ export class MemoryThreadStore implements ThreadRepository {
|
|
|
290
297
|
export type SessionRepository = ThreadRepository;
|
|
291
298
|
/** @deprecated use ThreadStore */
|
|
292
299
|
export const SessionStore = ThreadStore;
|
|
300
|
+
|
|
293
301
|
/** @deprecated use ThreadStore */
|
|
294
302
|
export type SessionStore = ThreadStore;
|
|
295
303
|
/** @deprecated use MemoryThreadStore */
|
|
296
304
|
export const MemorySessionStore = MemoryThreadStore;
|
|
305
|
+
|
|
297
306
|
/** @deprecated use MemoryThreadStore */
|
|
298
307
|
export type MemorySessionStore = MemoryThreadStore;
|
|
@@ -80,6 +80,17 @@ const StoredAgentSchema = v.object({
|
|
|
80
80
|
quote: v.optional(v.string()),
|
|
81
81
|
context: v.optional(v.string()),
|
|
82
82
|
status: v.picklist(["queued", "running", "completed", "failed"]),
|
|
83
|
+
cancelled: v.optional(v.boolean()),
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
promptOperations: v.optional(
|
|
88
|
+
v.array(
|
|
89
|
+
v.object({
|
|
90
|
+
operationId: v.string(),
|
|
91
|
+
fingerprint: v.string(),
|
|
92
|
+
result: v.array(v.string()),
|
|
93
|
+
outcome: v.optional(v.picklist(["completed", "failed", "cancelled"])),
|
|
83
94
|
}),
|
|
84
95
|
),
|
|
85
96
|
),
|
package/src/thread-agent.test.ts
CHANGED
|
@@ -72,10 +72,13 @@ test("a non-ACP harness streams and resumes through the same Thread manager", as
|
|
|
72
72
|
});
|
|
73
73
|
expect(manager.get(thread.id).messages[1]?.text).toBe("Native answer");
|
|
74
74
|
const promptDiscussion = { id: manager.get(thread.id).submissions![0]!.id };
|
|
75
|
+
|
|
75
76
|
expect(manager.isReadOnly(thread.id, promptDiscussion.id)).toBe(true);
|
|
76
77
|
const promptReply = manager.reply(thread.id, promptDiscussion.id, "The prompt is clear");
|
|
78
|
+
|
|
77
79
|
expect(promptReply.comments.at(-1)?.replyTo).toBe(promptDiscussion.id);
|
|
78
80
|
const answer = manager.get(thread.id).messages[1]!;
|
|
81
|
+
|
|
79
82
|
manager.comment({
|
|
80
83
|
id: thread.id,
|
|
81
84
|
comment: {
|
|
@@ -87,6 +90,7 @@ test("a non-ACP harness streams and resumes through the same Thread manager", as
|
|
|
87
90
|
},
|
|
88
91
|
});
|
|
89
92
|
const replied = manager.reply(thread.id, "answer-question", "This is the explanation");
|
|
93
|
+
|
|
90
94
|
expect(replied.comments.at(-1)).toMatchObject({
|
|
91
95
|
replyTo: "answer-question",
|
|
92
96
|
messageId: answer.id,
|
|
@@ -305,3 +309,104 @@ test("rejected first batches leave originals editable and create no queued turn"
|
|
|
305
309
|
rmSync(home, { recursive: true, force: true });
|
|
306
310
|
}
|
|
307
311
|
});
|
|
312
|
+
|
|
313
|
+
test("prompt operation receipts survive restart without requeueing accepted inputs", () => {
|
|
314
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-agent-receipt-"));
|
|
315
|
+
const thread = createTestThread(home);
|
|
316
|
+
const adapter: AgentHarnessAdapter = {
|
|
317
|
+
id: "receipt-test",
|
|
318
|
+
label: "Receipt test",
|
|
319
|
+
connect: () => ({
|
|
320
|
+
start: async () => "session",
|
|
321
|
+
prompt: () => new Promise(() => {}),
|
|
322
|
+
cancel() {},
|
|
323
|
+
permission() {},
|
|
324
|
+
close() {},
|
|
325
|
+
}),
|
|
326
|
+
};
|
|
327
|
+
const options = { home, enabled: true, adapter, getThread: () => thread, onChange() {} };
|
|
328
|
+
let manager = new ThreadAgentManager(options);
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const input = { id: thread.id, text: "Once", operationId: "once" };
|
|
332
|
+
const accepted = manager.prompt(input);
|
|
333
|
+
|
|
334
|
+
manager.dispose();
|
|
335
|
+
manager = new ThreadAgentManager(options);
|
|
336
|
+
const replay = manager.prompt(input);
|
|
337
|
+
|
|
338
|
+
expect(replay.promptOperations?.[0]?.result).toEqual(accepted.promptOperations?.[0]?.result);
|
|
339
|
+
expect(replay.submissions).toHaveLength(1);
|
|
340
|
+
expect(replay.promptOperations?.[0]?.outcome).toBe("failed");
|
|
341
|
+
expect(() => manager.prompt({ ...input, text: "Different" })).toThrow(
|
|
342
|
+
"Operation payload conflict",
|
|
343
|
+
);
|
|
344
|
+
manager.prompt({
|
|
345
|
+
id: thread.id,
|
|
346
|
+
text: "",
|
|
347
|
+
retry: replay.submissions![0]!.id,
|
|
348
|
+
operationId: "retry",
|
|
349
|
+
});
|
|
350
|
+
expect(manager.get(thread.id).promptOperations?.[0]?.outcome).toBe("failed");
|
|
351
|
+
} finally {
|
|
352
|
+
manager.dispose();
|
|
353
|
+
rmSync(home, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("retrying accepted work after a failed running-state save starts the harness once", async () => {
|
|
358
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-agent-start-save-"));
|
|
359
|
+
const thread = createTestThread(home);
|
|
360
|
+
const blockedPath = join(home, "thread-agents", `${thread.id}.json.tmp`);
|
|
361
|
+
let saves = 0;
|
|
362
|
+
let prompts = 0;
|
|
363
|
+
const started = Promise.withResolvers<void>();
|
|
364
|
+
const adapter: AgentHarnessAdapter = {
|
|
365
|
+
id: "start-save-test",
|
|
366
|
+
label: "Start save test",
|
|
367
|
+
connect: () => ({
|
|
368
|
+
start: async () => "session",
|
|
369
|
+
prompt: () => {
|
|
370
|
+
prompts++;
|
|
371
|
+
started.resolve();
|
|
372
|
+
|
|
373
|
+
return new Promise(() => {});
|
|
374
|
+
},
|
|
375
|
+
cancel() {},
|
|
376
|
+
permission() {},
|
|
377
|
+
close() {},
|
|
378
|
+
}),
|
|
379
|
+
};
|
|
380
|
+
const manager = new ThreadAgentManager({
|
|
381
|
+
home,
|
|
382
|
+
enabled: true,
|
|
383
|
+
adapter,
|
|
384
|
+
getThread: () => thread,
|
|
385
|
+
onChange() {
|
|
386
|
+
if (++saves === 1) mkdirSync(blockedPath);
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
const input = { id: thread.id, text: "Start once", operationId: "start-once" };
|
|
392
|
+
|
|
393
|
+
expect(() => manager.prompt(input)).toThrow();
|
|
394
|
+
expect(manager.get(thread.id).phase.kind).toBe("idle");
|
|
395
|
+
expect(manager.get(thread.id).submissions?.[0]?.status).toBe("queued");
|
|
396
|
+
expect(prompts).toBe(0);
|
|
397
|
+
const acceptedIds = manager.get(thread.id).promptOperations?.[0]?.result;
|
|
398
|
+
|
|
399
|
+
rmSync(blockedPath, { recursive: true });
|
|
400
|
+
const replay = manager.prompt(input);
|
|
401
|
+
|
|
402
|
+
expect(replay.promptOperations?.[0]?.result).toEqual(acceptedIds);
|
|
403
|
+
expect(replay.submissions).toHaveLength(1);
|
|
404
|
+
await started.promise;
|
|
405
|
+
expect(prompts).toBe(1);
|
|
406
|
+
manager.prompt(input);
|
|
407
|
+
expect(prompts).toBe(1);
|
|
408
|
+
} finally {
|
|
409
|
+
manager.dispose();
|
|
410
|
+
rmSync(home, { recursive: true, force: true });
|
|
411
|
+
}
|
|
412
|
+
});
|
package/src/thread-agent.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
stepAgentSubmission,
|
|
10
10
|
isAgentNote,
|
|
11
11
|
type AgentSubmission,
|
|
12
|
+
type AgentPromptRequest,
|
|
12
13
|
type AgentHarnessTools,
|
|
13
14
|
resolveAnchor,
|
|
14
15
|
type AgentComment,
|
|
@@ -19,6 +20,11 @@ import {
|
|
|
19
20
|
type AgentHarnessEvent,
|
|
20
21
|
type AgentHarnessResult,
|
|
21
22
|
} from "@cueloop/schema";
|
|
23
|
+
import {
|
|
24
|
+
findPromptOperationReceipt,
|
|
25
|
+
recordPromptOperation,
|
|
26
|
+
settlePromptOperations,
|
|
27
|
+
} from "./operation-receipts";
|
|
22
28
|
import { writeHarnessDiagnostic } from "./harness-diagnostics";
|
|
23
29
|
import { stepAgentTurn, agentTurnCancelled, type AgentTurn } from "./agent-turn";
|
|
24
30
|
import { ThreadAgentSchema } from "./thread-agent-validation";
|
|
@@ -90,7 +96,11 @@ export class ThreadAgentManager {
|
|
|
90
96
|
};
|
|
91
97
|
|
|
92
98
|
if (state.threadId !== id) throw new Error("Thread agent record has the wrong thread identity");
|
|
93
|
-
if (
|
|
99
|
+
if (
|
|
100
|
+
state.phase.kind === "running" ||
|
|
101
|
+
state.phase.kind === "permission" ||
|
|
102
|
+
state.submissions?.some((entry) => entry.status === "queued" || entry.status === "running")
|
|
103
|
+
) {
|
|
94
104
|
state.phase = {
|
|
95
105
|
kind: "failed",
|
|
96
106
|
error: "Agent was interrupted by a daemon restart. Send a message to resume.",
|
|
@@ -103,21 +113,29 @@ export class ThreadAgentManager {
|
|
|
103
113
|
if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
|
|
104
114
|
}
|
|
105
115
|
}
|
|
116
|
+
settlePromptOperations(state);
|
|
106
117
|
this.states.set(id, state);
|
|
107
118
|
|
|
108
119
|
return structuredClone(state);
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
/** Freeze each pending comment once and queue an individual answer at the Thread tail. */
|
|
112
|
-
prompt(params:
|
|
123
|
+
prompt(params: AgentPromptRequest): ThreadAgentState {
|
|
113
124
|
this.assertEnabled(params.id);
|
|
114
|
-
if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
|
|
115
125
|
const state = this.mutable(params.id);
|
|
116
126
|
const thread = this.options.getThread(params.id);
|
|
127
|
+
const receipt = findPromptOperationReceipt(state, params);
|
|
128
|
+
|
|
129
|
+
if (receipt) {
|
|
130
|
+
this.drain(state);
|
|
117
131
|
|
|
132
|
+
return structuredClone(state);
|
|
133
|
+
}
|
|
134
|
+
if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
|
|
118
135
|
if (thread.status !== "pending") throw new Error("Thread agent review is already resolved");
|
|
119
136
|
assertHarnessIdentity(state, this.options.adapter);
|
|
120
137
|
const before = structuredClone(state);
|
|
138
|
+
|
|
121
139
|
try {
|
|
122
140
|
state.harness ??= { id: this.options.adapter.id, label: this.options.adapter.label };
|
|
123
141
|
state.submissions ??= [];
|
|
@@ -141,6 +159,7 @@ export class ThreadAgentManager {
|
|
|
141
159
|
(total, message) => total + Buffer.byteLength(message.text),
|
|
142
160
|
0,
|
|
143
161
|
);
|
|
162
|
+
|
|
144
163
|
if (used + Buffer.byteLength(input.prompt) > 2 * 1024 * 1024)
|
|
145
164
|
throw new Error("Thread agent input exceeds transcript limit");
|
|
146
165
|
const submission: AgentSubmission = {
|
|
@@ -166,9 +185,11 @@ export class ThreadAgentManager {
|
|
|
166
185
|
|
|
167
186
|
if (!submission || submission.status !== "failed")
|
|
168
187
|
throw new Error("Thread agent retry requires a failed submission");
|
|
188
|
+
delete submission.cancelled;
|
|
169
189
|
submission.status = stepAgentSubmission(submission.status, "retry", true);
|
|
170
190
|
} else {
|
|
171
191
|
const accepted = new Set(submissions.map((entry) => entry.commentId));
|
|
192
|
+
|
|
172
193
|
for (const annotation of thread.annotations) {
|
|
173
194
|
if (!isPendingAgentInput(annotation, accepted)) continue;
|
|
174
195
|
const root =
|
|
@@ -182,6 +203,7 @@ export class ThreadAgentManager {
|
|
|
182
203
|
enqueue({ commentId: annotation.id, prompt: annotation.body, quote: root.anchor.quote });
|
|
183
204
|
// The discussion snapshot is passed as context, while the mirror shows only the new comment.
|
|
184
205
|
const submission = submissions.at(-1)!;
|
|
206
|
+
|
|
185
207
|
submission.context = JSON.stringify({
|
|
186
208
|
target: root.target,
|
|
187
209
|
discussion: discussion.map((entry) => ({ id: entry.id, body: entry.body })),
|
|
@@ -195,6 +217,7 @@ export class ThreadAgentManager {
|
|
|
195
217
|
messageId: comment.messageId,
|
|
196
218
|
});
|
|
197
219
|
const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
|
|
220
|
+
|
|
198
221
|
submissions.at(-1)!.context = JSON.stringify({
|
|
199
222
|
discussion: [root, ...state.comments.filter((entry) => entry.replyTo === root.id)].map(
|
|
200
223
|
(entry) => ({ id: entry.id, body: entry.body }),
|
|
@@ -204,6 +227,7 @@ export class ThreadAgentManager {
|
|
|
204
227
|
}
|
|
205
228
|
if (params.text.trim()) enqueue({ prompt: params.text.trim(), quote: params.context });
|
|
206
229
|
}
|
|
230
|
+
recordPromptOperation(state, before, params);
|
|
207
231
|
this.save(state);
|
|
208
232
|
} catch (error) {
|
|
209
233
|
restoreAgentState(state, before);
|
|
@@ -231,14 +255,20 @@ export class ThreadAgentManager {
|
|
|
231
255
|
if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
|
|
232
256
|
const state = this.mutable(params.id);
|
|
233
257
|
|
|
234
|
-
if (state.phase.kind === "running" || state.phase.kind === "permission")
|
|
258
|
+
if (state.phase.kind === "running" || state.phase.kind === "permission") {
|
|
259
|
+
// Discovery can read existing choices without reconfiguring an active turn.
|
|
260
|
+
if (params.configId === undefined || params.value === undefined)
|
|
261
|
+
return structuredClone(state);
|
|
235
262
|
throw new Error("Thread agent configuration waits for the current turn");
|
|
263
|
+
}
|
|
236
264
|
const active = await this.connect(state, this.options.getThread(params.id), "", 1);
|
|
237
265
|
|
|
238
266
|
if (params.configId === undefined || params.value === undefined) {
|
|
239
267
|
const current = this.get(params.id);
|
|
268
|
+
|
|
240
269
|
if (current.phase.kind !== "running" && current.phase.kind !== "permission")
|
|
241
270
|
active.turn = stepAgentTurn(active.turn, "finished");
|
|
271
|
+
|
|
242
272
|
return structuredClone(state);
|
|
243
273
|
}
|
|
244
274
|
if (!active.connection.configure)
|
|
@@ -260,6 +290,8 @@ export class ThreadAgentManager {
|
|
|
260
290
|
const submission = state.submissions?.find((entry) => entry.status === "queued");
|
|
261
291
|
|
|
262
292
|
if (!submission) return;
|
|
293
|
+
const before = structuredClone(state);
|
|
294
|
+
|
|
263
295
|
submission.status = stepAgentSubmission(submission.status, "start", true);
|
|
264
296
|
state.phase = { kind: "running" };
|
|
265
297
|
const thread = this.options.getThread(state.threadId);
|
|
@@ -278,7 +310,13 @@ Quote: ${submission.quote ?? ""}
|
|
|
278
310
|
Discussion context: ${submission.context ?? submission.messageId ?? ""}
|
|
279
311
|
Input: ${submission.prompt}`;
|
|
280
312
|
|
|
281
|
-
|
|
313
|
+
try {
|
|
314
|
+
this.save(state);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
// Acceptance is already durable; a retry can resume the queue before any harness request.
|
|
317
|
+
restoreAgentState(state, before);
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
282
320
|
void this.run(state, thread, submission.id, revision, prompt, [], submission);
|
|
283
321
|
}
|
|
284
322
|
|
|
@@ -324,8 +362,10 @@ Input: ${submission.prompt}`;
|
|
|
324
362
|
reply(id: string, commentId: string, body: string): ThreadAgentState {
|
|
325
363
|
const state = this.mutable(id);
|
|
326
364
|
const comment = agentCommentRoot(state, commentId);
|
|
365
|
+
|
|
327
366
|
if (!comment) throw new Error("Thread agent reply comment does not exist");
|
|
328
367
|
const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
|
|
368
|
+
|
|
329
369
|
if (state.comments.length >= 128)
|
|
330
370
|
throw new Error("Thread agent prototype reached its comment limit");
|
|
331
371
|
state.comments.push({
|
|
@@ -348,6 +388,9 @@ Input: ${submission.prompt}`;
|
|
|
348
388
|
|
|
349
389
|
if (active && (state.phase.kind === "running" || state.phase.kind === "permission")) {
|
|
350
390
|
active.turn = stepAgentTurn(active.turn, "stop");
|
|
391
|
+
const submission = state.submissions?.find((entry) => entry.status === "running");
|
|
392
|
+
|
|
393
|
+
if (submission) submission.cancelled = true;
|
|
351
394
|
for (const tool of state.tools) {
|
|
352
395
|
if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
|
|
353
396
|
}
|
|
@@ -410,6 +453,7 @@ Input: ${submission.prompt}`;
|
|
|
410
453
|
}
|
|
411
454
|
|
|
412
455
|
private save(state: ThreadAgentState): void {
|
|
456
|
+
settlePromptOperations(state);
|
|
413
457
|
if (this.states.get(state.threadId) !== state) return;
|
|
414
458
|
const data = JSON.stringify(state);
|
|
415
459
|
|
|
@@ -449,6 +493,7 @@ Input: ${submission.prompt}`;
|
|
|
449
493
|
const result = await active.connection.prompt(prompt);
|
|
450
494
|
|
|
451
495
|
finalizeAgentMessages(state, messageStart, result.outcome);
|
|
496
|
+
if (submission && result.outcome === "cancelled") submission.cancelled = true;
|
|
452
497
|
if (submission)
|
|
453
498
|
submission.status = stepAgentSubmission(
|
|
454
499
|
submission.status,
|
|
@@ -485,6 +530,7 @@ Input: ${submission.prompt}`;
|
|
|
485
530
|
revision: number,
|
|
486
531
|
): Promise<ActiveAgent> {
|
|
487
532
|
const pending = this.initializing.get(state.threadId);
|
|
533
|
+
|
|
488
534
|
if (pending) return pending;
|
|
489
535
|
const promise = this.startConnection(state, thread, turnId, revision);
|
|
490
536
|
|
|
@@ -551,6 +597,7 @@ Input: ${submission.prompt}`;
|
|
|
551
597
|
if (event.kind === "config") {
|
|
552
598
|
state.configOptions = event.options;
|
|
553
599
|
this.save(state);
|
|
600
|
+
|
|
554
601
|
return;
|
|
555
602
|
}
|
|
556
603
|
const active = this.active.get(state.threadId);
|
|
@@ -656,6 +703,7 @@ function isPendingAgentInput(
|
|
|
656
703
|
|
|
657
704
|
function restoreAgentState(state: ThreadAgentState, before: ThreadAgentState): void {
|
|
658
705
|
if (before.submissions === undefined) delete state.submissions;
|
|
706
|
+
if (before.promptOperations === undefined) delete state.promptOperations;
|
|
659
707
|
if (before.harness === undefined) delete state.harness;
|
|
660
708
|
Object.assign(state, before);
|
|
661
709
|
}
|
package/src/thread-review.ts
CHANGED
|
@@ -74,6 +74,7 @@ export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKe
|
|
|
74
74
|
const remote = nativeJjRoot ? null : gitRemote;
|
|
75
75
|
|
|
76
76
|
const workspace: WorkspaceKey = { repoRoot, branch };
|
|
77
|
+
|
|
77
78
|
// a repo with no commits (or a shallow clone) has no reliable root, so the thread stays standalone
|
|
78
79
|
if (rootCommit) workspace.rootCommit = rootCommit;
|
|
79
80
|
if (remote) workspace.remote = remote;
|