@cueloop/daemon 0.1.0-alpha.100 → 0.1.0-alpha.101
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 +5 -4
- package/src/operation-receipts.ts +8 -1
- package/src/server.test.ts +46 -0
- package/src/server.ts +36 -6
- package/src/shared-agent-protocol.ts +30 -0
- package/src/thread-agent-validation.ts +2 -0
- package/src/thread-agent.test.ts +307 -0
- package/src/thread-agent.ts +228 -106
- package/src/validate.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.101",
|
|
4
4
|
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -37,14 +37,15 @@
|
|
|
37
37
|
"./curate": "./src/curate.ts",
|
|
38
38
|
"./sdk": "./src/daemon-sdk.ts",
|
|
39
39
|
"./sdk-effect": "./src/daemon-sdk-effect.ts",
|
|
40
|
-
"./thread-subscription": "./src/thread-subscription.ts"
|
|
40
|
+
"./thread-subscription": "./src/thread-subscription.ts",
|
|
41
|
+
"./shared-agent-protocol": "./src/shared-agent-protocol.ts"
|
|
41
42
|
},
|
|
42
43
|
"publishConfig": {
|
|
43
44
|
"access": "public"
|
|
44
45
|
},
|
|
45
46
|
"dependencies": {
|
|
46
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
47
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
47
|
+
"@cueloop/extension-api": "0.1.0-alpha.101",
|
|
48
|
+
"@cueloop/schema": "0.1.0-alpha.101",
|
|
48
49
|
"@pierre/diffs": "^1.5.1",
|
|
49
50
|
"effect": "^4.0.0",
|
|
50
51
|
"smol-toml": "^1.9.0",
|
|
@@ -87,5 +87,12 @@ export function findPromptOperationReceipt(
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
function promptOperationFingerprint(input: AgentPromptRequest): string {
|
|
90
|
-
return operationFingerprint([
|
|
90
|
+
return operationFingerprint([
|
|
91
|
+
input.text,
|
|
92
|
+
input.context ?? null,
|
|
93
|
+
input.retry ?? null,
|
|
94
|
+
input.commentId ?? null,
|
|
95
|
+
input.inputOnly ? "input" : null,
|
|
96
|
+
input.discussion ?? null,
|
|
97
|
+
]);
|
|
91
98
|
}
|
package/src/server.test.ts
CHANGED
|
@@ -526,3 +526,49 @@ describe("large payloads survive socket backpressure", () => {
|
|
|
526
526
|
expect(listed.some((session) => session.id === created.id)).toBe(true);
|
|
527
527
|
}, 15_000);
|
|
528
528
|
});
|
|
529
|
+
|
|
530
|
+
test("explicit daemon shutdown waits for the harness to release durable storage before exit", async () => {
|
|
531
|
+
client.close();
|
|
532
|
+
server.stop();
|
|
533
|
+
const closeStarted = Promise.withResolvers<void>();
|
|
534
|
+
const release = Promise.withResolvers<void>();
|
|
535
|
+
const exited = Promise.withResolvers<void>();
|
|
536
|
+
let didExit = false;
|
|
537
|
+
|
|
538
|
+
server = new DaemonServer({
|
|
539
|
+
home,
|
|
540
|
+
idleExitMs: 0,
|
|
541
|
+
onIdleExit: () => {
|
|
542
|
+
didExit = true;
|
|
543
|
+
exited.resolve();
|
|
544
|
+
},
|
|
545
|
+
threadAgent: {
|
|
546
|
+
enabled: true,
|
|
547
|
+
adapter: {
|
|
548
|
+
id: "pi",
|
|
549
|
+
label: "pi",
|
|
550
|
+
connect: () => ({
|
|
551
|
+
start: async () => "session",
|
|
552
|
+
prompt: async () => ({ outcome: "completed" }),
|
|
553
|
+
cancel() {},
|
|
554
|
+
permission() {},
|
|
555
|
+
close: async () => {
|
|
556
|
+
closeStarted.resolve();
|
|
557
|
+
await release.promise;
|
|
558
|
+
},
|
|
559
|
+
}),
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
server.start();
|
|
564
|
+
client = await DaemonClient.connect({ home });
|
|
565
|
+
const thread = await client.sessionCreate(WS, PLAN);
|
|
566
|
+
|
|
567
|
+
await client.agentPrompt({ id: thread.id, text: "Persist this" });
|
|
568
|
+
await client.shutdown();
|
|
569
|
+
await closeStarted.promise;
|
|
570
|
+
expect(didExit).toBe(false);
|
|
571
|
+
release.resolve();
|
|
572
|
+
await exited.promise;
|
|
573
|
+
expect(didExit).toBe(true);
|
|
574
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -45,8 +45,9 @@ interface Connection {
|
|
|
45
45
|
type MethodHandler = (connection: Connection, request: Request) => Response["result"];
|
|
46
46
|
|
|
47
47
|
export interface DaemonOptions {
|
|
48
|
-
threadAgent?: Pick<ThreadAgentOptions, "enabled" | "enabledForThread" | "adapter">;
|
|
48
|
+
threadAgent?: Pick<ThreadAgentOptions, "enabled" | "enabledForThread" | "adapter" | "adapters">;
|
|
49
49
|
home?: string;
|
|
50
|
+
onEvent?: (event: import("./client").EventFrame) => void;
|
|
50
51
|
idleExitMs?: number;
|
|
51
52
|
onIdleExit?: () => void;
|
|
52
53
|
version?: string;
|
|
@@ -73,8 +74,10 @@ export class DaemonServer {
|
|
|
73
74
|
private readonly idleExitMs: number;
|
|
74
75
|
private readonly onIdleExit: () => void;
|
|
75
76
|
private readonly version: string;
|
|
77
|
+
private readonly onEvent?: (event: import("./client").EventFrame) => void;
|
|
76
78
|
|
|
77
79
|
constructor(options: DaemonOptions = {}) {
|
|
80
|
+
this.onEvent = options.onEvent;
|
|
78
81
|
this.home = options.home ?? cueloopHome();
|
|
79
82
|
this.idleExitMs = options.idleExitMs ?? 15 * 60 * 1000;
|
|
80
83
|
this.onIdleExit = options.onIdleExit ?? (() => process.exit(0));
|
|
@@ -86,6 +89,7 @@ export class DaemonServer {
|
|
|
86
89
|
enabled: options.threadAgent?.enabled ?? false,
|
|
87
90
|
enabledForThread: options.threadAgent?.enabledForThread,
|
|
88
91
|
adapter: options.threadAgent?.adapter,
|
|
92
|
+
adapters: options.threadAgent?.adapters,
|
|
89
93
|
getThread: (id) => this.core.sessionGet(id),
|
|
90
94
|
tools: (thread): AgentHarnessTools => ({
|
|
91
95
|
definitions: [
|
|
@@ -131,6 +135,7 @@ export class DaemonServer {
|
|
|
131
135
|
call: async (name, input) => this.callAgentTool(thread.id, name, input),
|
|
132
136
|
}),
|
|
133
137
|
onChange: (id) => {
|
|
138
|
+
this.onEvent?.({ event: "agent.updated", sessionId: id });
|
|
134
139
|
for (const connection of this.connections) {
|
|
135
140
|
if (connection.subscribed && connection.role === "owner")
|
|
136
141
|
connection.write(JSON.stringify({ event: "agent.updated", sessionId: id }) + "\n");
|
|
@@ -286,8 +291,28 @@ export class DaemonServer {
|
|
|
286
291
|
return path;
|
|
287
292
|
}
|
|
288
293
|
|
|
294
|
+
resumeAgents(): void {
|
|
295
|
+
for (const thread of this.core.sessionList({ status: "pending" })) {
|
|
296
|
+
if (!this.threadAgent.isEnabled(thread.id)) continue;
|
|
297
|
+
const state = this.threadAgent.get(thread.id);
|
|
298
|
+
|
|
299
|
+
if (
|
|
300
|
+
state.harness?.id === "pi" &&
|
|
301
|
+
state.submissions?.some((submission) => submission.status === "queued")
|
|
302
|
+
)
|
|
303
|
+
void this.threadAgent
|
|
304
|
+
.configure({ id: thread.id })
|
|
305
|
+
.catch((error) => console.error("[agent recovery]", error));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async shutdown(): Promise<void> {
|
|
310
|
+
await this.threadAgent.dispose();
|
|
311
|
+
this.stop();
|
|
312
|
+
}
|
|
313
|
+
|
|
289
314
|
stop(): void {
|
|
290
|
-
this.threadAgent.dispose();
|
|
315
|
+
void this.threadAgent.dispose().catch((error) => console.error("[agent shutdown]", error));
|
|
291
316
|
this.core.dispose();
|
|
292
317
|
if (this.lockFd === null) return;
|
|
293
318
|
this.server?.stop(true);
|
|
@@ -300,6 +325,7 @@ export class DaemonServer {
|
|
|
300
325
|
}
|
|
301
326
|
|
|
302
327
|
private broadcast(event: DaemonEvent): void {
|
|
328
|
+
this.onEvent?.(event);
|
|
303
329
|
const frame = JSON.stringify(event) + "\n";
|
|
304
330
|
|
|
305
331
|
for (const connection of this.connections) if (connection.subscribed) connection.write(frame);
|
|
@@ -473,8 +499,9 @@ export class DaemonServer {
|
|
|
473
499
|
},
|
|
474
500
|
"daemon.shutdown": () => {
|
|
475
501
|
setTimeout(() => {
|
|
476
|
-
this.
|
|
477
|
-
|
|
502
|
+
void this.shutdown()
|
|
503
|
+
.then(() => this.onIdleExit())
|
|
504
|
+
.catch((error) => console.error("[agent shutdown]", error));
|
|
478
505
|
}, 10);
|
|
479
506
|
|
|
480
507
|
return {};
|
|
@@ -632,13 +659,16 @@ export class DaemonServer {
|
|
|
632
659
|
"session.setShares": (_connection, request) => {
|
|
633
660
|
const params = parseParams("session.setShares", request.params);
|
|
634
661
|
|
|
662
|
+
if (params.shares.some((link) => link.agentEnabled))
|
|
663
|
+
this.threadAgent.assertEnabled(params.id);
|
|
664
|
+
|
|
635
665
|
return this.core.sessionSetShares(params.id, params.shares);
|
|
636
666
|
},
|
|
637
|
-
"session.delete": (_connection, request) => {
|
|
667
|
+
"session.delete": async (_connection, request) => {
|
|
638
668
|
const { id } = parseParams("session.delete", request.params);
|
|
639
669
|
|
|
670
|
+
await this.threadAgent.remove(id);
|
|
640
671
|
this.core.sessionDelete(id);
|
|
641
|
-
this.threadAgent.remove(id);
|
|
642
672
|
|
|
643
673
|
return {};
|
|
644
674
|
},
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
import { ThreadRecordSchema, Params } from "./validate";
|
|
3
|
+
import { AgentCommentSchema, ThreadAgentSchema } from "./thread-agent-validation";
|
|
4
|
+
|
|
5
|
+
/** Both SSH endpoints validate relay frames before using owner or collaborator data. */
|
|
6
|
+
export const SharedAgentRequestSchema = v.object({
|
|
7
|
+
author: v.string(),
|
|
8
|
+
params: v.object({
|
|
9
|
+
...Params["agent.prompt"].entries,
|
|
10
|
+
operationId: v.pipe(v.string(), v.minLength(1), v.maxLength(128)),
|
|
11
|
+
}),
|
|
12
|
+
comment: v.optional(AgentCommentSchema),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** A relay cannot carry executable configuration, credentials, or arbitrary daemon methods. */
|
|
16
|
+
export const SharedAgentFrameSchema = v.variant("type", [
|
|
17
|
+
v.object({ type: v.literal("hello"), shareId: v.string() }),
|
|
18
|
+
v.object({
|
|
19
|
+
type: v.literal("requests"),
|
|
20
|
+
thread: ThreadRecordSchema,
|
|
21
|
+
requests: v.array(SharedAgentRequestSchema),
|
|
22
|
+
}),
|
|
23
|
+
v.object({
|
|
24
|
+
type: v.literal("state"),
|
|
25
|
+
state: ThreadAgentSchema,
|
|
26
|
+
accepted: v.optional(v.array(v.string())),
|
|
27
|
+
}),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export { ThreadAgentSchema, AgentCommentSchema } from "./thread-agent-validation";
|
|
@@ -32,6 +32,7 @@ const StoredAgentSchema = v.object({
|
|
|
32
32
|
phase: v.variant("kind", [
|
|
33
33
|
v.object({ kind: v.literal("idle") }),
|
|
34
34
|
v.object({ kind: v.literal("running") }),
|
|
35
|
+
v.object({ kind: v.literal("offline") }),
|
|
35
36
|
v.object({ kind: v.literal("failed"), error: v.string() }),
|
|
36
37
|
v.object({
|
|
37
38
|
kind: v.literal("permission"),
|
|
@@ -77,6 +78,7 @@ const StoredAgentSchema = v.object({
|
|
|
77
78
|
commentId: v.optional(v.string()),
|
|
78
79
|
messageId: v.optional(v.string()),
|
|
79
80
|
prompt: v.string(),
|
|
81
|
+
harnessPrompt: v.optional(v.string()),
|
|
80
82
|
quote: v.optional(v.string()),
|
|
81
83
|
context: v.optional(v.string()),
|
|
82
84
|
status: v.picklist(["queued", "running", "completed", "failed"]),
|
package/src/thread-agent.test.ts
CHANGED
|
@@ -410,3 +410,310 @@ test("retrying accepted work after a failed running-state save starts the harnes
|
|
|
410
410
|
rmSync(home, { recursive: true, force: true });
|
|
411
411
|
}
|
|
412
412
|
});
|
|
413
|
+
|
|
414
|
+
test("a shared plain input does not consume the owner's pending comments", () => {
|
|
415
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-agent-input-only-"));
|
|
416
|
+
const thread = createTestThread(home);
|
|
417
|
+
const adapter: AgentHarnessAdapter = {
|
|
418
|
+
id: "pi",
|
|
419
|
+
label: "pi",
|
|
420
|
+
connect: () => ({
|
|
421
|
+
start: () => new Promise(() => {}),
|
|
422
|
+
prompt: () => new Promise(() => {}),
|
|
423
|
+
cancel() {},
|
|
424
|
+
permission() {},
|
|
425
|
+
close() {},
|
|
426
|
+
}),
|
|
427
|
+
};
|
|
428
|
+
const manager = new ThreadAgentManager({
|
|
429
|
+
home,
|
|
430
|
+
enabled: true,
|
|
431
|
+
adapter,
|
|
432
|
+
getThread: () => thread,
|
|
433
|
+
onChange() {},
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
thread.annotations = [
|
|
437
|
+
{
|
|
438
|
+
id: "owner-note",
|
|
439
|
+
author: "owner",
|
|
440
|
+
kind: "comment",
|
|
441
|
+
body: "Private draft",
|
|
442
|
+
anchor: { quote: "Review", prefix: "", suffix: " the retry" },
|
|
443
|
+
createdAt: "now",
|
|
444
|
+
},
|
|
445
|
+
];
|
|
446
|
+
try {
|
|
447
|
+
const state = manager.prompt({
|
|
448
|
+
id: thread.id,
|
|
449
|
+
text: "Shared question",
|
|
450
|
+
operationId: "shared-input",
|
|
451
|
+
inputOnly: true,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
expect(state.messages.map((entry) => entry.text)).toEqual(["Shared question"]);
|
|
455
|
+
expect(manager.isReadOnly(thread.id, "owner-note")).toBe(false);
|
|
456
|
+
expect(
|
|
457
|
+
manager.prompt({
|
|
458
|
+
id: thread.id,
|
|
459
|
+
text: "Shared question",
|
|
460
|
+
operationId: "shared-input",
|
|
461
|
+
inputOnly: true,
|
|
462
|
+
}).messages,
|
|
463
|
+
).toHaveLength(1);
|
|
464
|
+
} finally {
|
|
465
|
+
manager.dispose();
|
|
466
|
+
rmSync(home, { recursive: true, force: true });
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("Pi recovery keeps the frozen harness input and submission identity after interruption", async () => {
|
|
471
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-pi-recovery-"));
|
|
472
|
+
const thread = createTestThread(home);
|
|
473
|
+
const prompts: Array<{ text: string; id?: string }> = [];
|
|
474
|
+
let resumed = false;
|
|
475
|
+
const adapter: AgentHarnessAdapter = {
|
|
476
|
+
id: "pi",
|
|
477
|
+
label: "pi",
|
|
478
|
+
connect: ({ onEvent }) => ({
|
|
479
|
+
start: async () => "durable-session",
|
|
480
|
+
prompt: async (text, id) => {
|
|
481
|
+
prompts.push({ text, id });
|
|
482
|
+
if (!resumed) return new Promise(() => {});
|
|
483
|
+
onEvent({ kind: "message", id: "durable-answer", text: "Recovered answer", replace: true });
|
|
484
|
+
|
|
485
|
+
return { outcome: "completed" as const };
|
|
486
|
+
},
|
|
487
|
+
cancel() {},
|
|
488
|
+
permission() {},
|
|
489
|
+
close() {},
|
|
490
|
+
}),
|
|
491
|
+
};
|
|
492
|
+
const options = { home, enabled: true, adapter, getThread: () => thread, onChange() {} };
|
|
493
|
+
let manager = new ThreadAgentManager(options);
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
manager.prompt({
|
|
497
|
+
id: thread.id,
|
|
498
|
+
text: "Recover",
|
|
499
|
+
operationId: "recover-input",
|
|
500
|
+
inputOnly: true,
|
|
501
|
+
});
|
|
502
|
+
for (let turn = 0; !prompts.length && turn < 100; turn++) await Bun.sleep(1);
|
|
503
|
+
await manager.dispose();
|
|
504
|
+
thread.artifact.content = "Changed artifact after admission";
|
|
505
|
+
resumed = true;
|
|
506
|
+
manager = new ThreadAgentManager(options);
|
|
507
|
+
expect(manager.get(thread.id).submissions?.[0]?.status).toBe("queued");
|
|
508
|
+
manager.prompt({
|
|
509
|
+
id: thread.id,
|
|
510
|
+
text: "Recover",
|
|
511
|
+
operationId: "recover-input",
|
|
512
|
+
inputOnly: true,
|
|
513
|
+
});
|
|
514
|
+
for (let turn = 0; manager.get(thread.id).phase.kind !== "idle" && turn < 100; turn++)
|
|
515
|
+
await Bun.sleep(1);
|
|
516
|
+
expect(prompts).toHaveLength(2);
|
|
517
|
+
expect(prompts[1]).toEqual(prompts[0]);
|
|
518
|
+
expect(manager.get(thread.id).messages.map((message) => message.text)).toEqual([
|
|
519
|
+
"Recover",
|
|
520
|
+
"Recovered answer",
|
|
521
|
+
]);
|
|
522
|
+
} finally {
|
|
523
|
+
await manager.dispose();
|
|
524
|
+
rmSync(home, { recursive: true, force: true });
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("explicit shared comments execute the frozen payload and retain the original discussion", async () => {
|
|
529
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-shared-frozen-"));
|
|
530
|
+
const thread = createTestThread(home);
|
|
531
|
+
const original = {
|
|
532
|
+
id: "root",
|
|
533
|
+
kind: "comment" as const,
|
|
534
|
+
body: "Original discussion",
|
|
535
|
+
anchor: { quote: "retry", prefix: "Review the ", suffix: "" },
|
|
536
|
+
createdAt: "now",
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
thread.annotations = [
|
|
540
|
+
original,
|
|
541
|
+
{ ...original, id: "reply", replyTo: "root", body: "Later edited value" },
|
|
542
|
+
];
|
|
543
|
+
const manager = new ThreadAgentManager({
|
|
544
|
+
home,
|
|
545
|
+
enabled: true,
|
|
546
|
+
adapter: {
|
|
547
|
+
id: "pi",
|
|
548
|
+
label: "pi",
|
|
549
|
+
connect: () => ({
|
|
550
|
+
start: async () => "session",
|
|
551
|
+
prompt: async () => ({ outcome: "completed" }),
|
|
552
|
+
cancel() {},
|
|
553
|
+
close() {},
|
|
554
|
+
permission() {},
|
|
555
|
+
}),
|
|
556
|
+
},
|
|
557
|
+
getThread: () => thread,
|
|
558
|
+
onChange() {},
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
const state = manager.prompt({
|
|
563
|
+
id: thread.id,
|
|
564
|
+
commentId: "reply",
|
|
565
|
+
operationId: "shared-frozen",
|
|
566
|
+
text: "Accepted reply",
|
|
567
|
+
context: "retry",
|
|
568
|
+
discussion: JSON.stringify({
|
|
569
|
+
discussion: [{ body: "Original discussion" }, { body: "Accepted reply" }],
|
|
570
|
+
}),
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
expect(state.submissions?.[0]?.prompt).toBe("Accepted reply");
|
|
574
|
+
expect(state.submissions?.[0]?.context).toContain("Original discussion");
|
|
575
|
+
expect(state.submissions?.[0]?.context).not.toContain("Later edited value");
|
|
576
|
+
} finally {
|
|
577
|
+
await manager.dispose();
|
|
578
|
+
rmSync(home, { recursive: true, force: true });
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("a failed connection finishes asynchronous close before the next queued turn opens storage", async () => {
|
|
583
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-agent-close-"));
|
|
584
|
+
const thread = createTestThread(home);
|
|
585
|
+
const closeStarted = Promise.withResolvers<void>();
|
|
586
|
+
const release = Promise.withResolvers<void>();
|
|
587
|
+
let connects = 0;
|
|
588
|
+
const manager = new ThreadAgentManager({
|
|
589
|
+
home,
|
|
590
|
+
enabled: true,
|
|
591
|
+
getThread: () => thread,
|
|
592
|
+
onChange() {},
|
|
593
|
+
adapter: {
|
|
594
|
+
id: "pi",
|
|
595
|
+
label: "pi",
|
|
596
|
+
connect: () => {
|
|
597
|
+
const first = ++connects === 1;
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
start: async () => "session",
|
|
601
|
+
prompt: async () => {
|
|
602
|
+
if (first) throw new Error("Connection failed");
|
|
603
|
+
|
|
604
|
+
return { outcome: "completed" };
|
|
605
|
+
},
|
|
606
|
+
cancel() {},
|
|
607
|
+
permission() {},
|
|
608
|
+
close: async () => {
|
|
609
|
+
if (first) {
|
|
610
|
+
closeStarted.resolve();
|
|
611
|
+
await release.promise;
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
try {
|
|
620
|
+
manager.prompt({ id: thread.id, text: "First" });
|
|
621
|
+
manager.prompt({ id: thread.id, text: "Second" });
|
|
622
|
+
await closeStarted.promise;
|
|
623
|
+
expect(connects).toBe(1);
|
|
624
|
+
release.resolve();
|
|
625
|
+
for (
|
|
626
|
+
let attempt = 0;
|
|
627
|
+
manager.get(thread.id).submissions?.[1]?.status !== "completed" && attempt < 100;
|
|
628
|
+
attempt++
|
|
629
|
+
)
|
|
630
|
+
await Bun.sleep(1);
|
|
631
|
+
expect(connects).toBe(2);
|
|
632
|
+
expect(manager.get(thread.id).submissions?.[1]?.status).toBe("completed");
|
|
633
|
+
} finally {
|
|
634
|
+
release.resolve();
|
|
635
|
+
await manager.dispose();
|
|
636
|
+
rmSync(home, { recursive: true, force: true });
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test("shutdown joins a failed turn that is already retiring its durable writer", async () => {
|
|
641
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-agent-retiring-"));
|
|
642
|
+
const thread = createTestThread(home);
|
|
643
|
+
const closeStarted = Promise.withResolvers<void>();
|
|
644
|
+
const release = Promise.withResolvers<void>();
|
|
645
|
+
let closed = false;
|
|
646
|
+
const manager = new ThreadAgentManager({
|
|
647
|
+
home,
|
|
648
|
+
enabled: true,
|
|
649
|
+
getThread: () => thread,
|
|
650
|
+
onChange() {},
|
|
651
|
+
adapter: {
|
|
652
|
+
id: "pi",
|
|
653
|
+
label: "pi",
|
|
654
|
+
connect: () => ({
|
|
655
|
+
start: async () => "session",
|
|
656
|
+
prompt: async () => {
|
|
657
|
+
throw new Error("Failed turn");
|
|
658
|
+
},
|
|
659
|
+
cancel() {},
|
|
660
|
+
permission() {},
|
|
661
|
+
close: async () => {
|
|
662
|
+
closeStarted.resolve();
|
|
663
|
+
await release.promise;
|
|
664
|
+
closed = true;
|
|
665
|
+
},
|
|
666
|
+
}),
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
try {
|
|
671
|
+
manager.prompt({ id: thread.id, text: "First" });
|
|
672
|
+
await closeStarted.promise;
|
|
673
|
+
const shutdown = manager.dispose();
|
|
674
|
+
let finished = false;
|
|
675
|
+
|
|
676
|
+
void shutdown.then(() => {
|
|
677
|
+
finished = true;
|
|
678
|
+
});
|
|
679
|
+
await Promise.resolve();
|
|
680
|
+
expect(finished).toBe(false);
|
|
681
|
+
release.resolve();
|
|
682
|
+
await shutdown;
|
|
683
|
+
expect(closed).toBe(true);
|
|
684
|
+
} finally {
|
|
685
|
+
release.resolve();
|
|
686
|
+
await manager.dispose();
|
|
687
|
+
rmSync(home, { recursive: true, force: true });
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("shutdown seals admission so a later configuration cannot open a new harness", async () => {
|
|
692
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-agent-sealed-"));
|
|
693
|
+
const thread = createTestThread(home);
|
|
694
|
+
let connects = 0;
|
|
695
|
+
const manager = new ThreadAgentManager({
|
|
696
|
+
home,
|
|
697
|
+
enabled: true,
|
|
698
|
+
getThread: () => thread,
|
|
699
|
+
onChange() {},
|
|
700
|
+
adapter: {
|
|
701
|
+
id: "pi",
|
|
702
|
+
label: "pi",
|
|
703
|
+
connect: () => {
|
|
704
|
+
connects++;
|
|
705
|
+
|
|
706
|
+
throw new Error("Must not connect after shutdown");
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
try {
|
|
712
|
+
await manager.dispose();
|
|
713
|
+
await expect(manager.configure({ id: thread.id })).rejects.toThrow("shutting down");
|
|
714
|
+
expect(() => manager.prompt({ id: thread.id, text: "Late input" })).toThrow("shutting down");
|
|
715
|
+
expect(connects).toBe(0);
|
|
716
|
+
} finally {
|
|
717
|
+
rmSync(home, { recursive: true, force: true });
|
|
718
|
+
}
|
|
719
|
+
});
|
package/src/thread-agent.ts
CHANGED
|
@@ -35,6 +35,7 @@ export interface ThreadAgentOptions {
|
|
|
35
35
|
enabled: boolean;
|
|
36
36
|
enabledForThread?: (thread: Thread) => boolean;
|
|
37
37
|
adapter?: AgentHarnessAdapter;
|
|
38
|
+
adapters?: Record<string, AgentHarnessAdapter>;
|
|
38
39
|
getThread: (id: string) => Thread;
|
|
39
40
|
onChange: (id: string) => void;
|
|
40
41
|
tools?: (thread: Thread) => AgentHarnessTools;
|
|
@@ -49,10 +50,13 @@ interface ActiveAgent {
|
|
|
49
50
|
|
|
50
51
|
/** Own harness processes and durable agent transcripts without mutating reviewed artifacts. */
|
|
51
52
|
export class ThreadAgentManager {
|
|
53
|
+
private readonly deleting = new Set<string>();
|
|
54
|
+
private readonly retiring = new Map<string, Promise<void>>();
|
|
52
55
|
private states = new Map<string, ThreadAgentState>();
|
|
53
56
|
private active = new Map<string, ActiveAgent>();
|
|
54
57
|
private readonly directory: string;
|
|
55
58
|
private disposed = false;
|
|
59
|
+
private closing?: Promise<void>;
|
|
56
60
|
private initializing = new Map<string, Promise<ActiveAgent>>();
|
|
57
61
|
|
|
58
62
|
constructor(private readonly options: ThreadAgentOptions) {
|
|
@@ -68,6 +72,7 @@ export class ThreadAgentManager {
|
|
|
68
72
|
|
|
69
73
|
/** Refuse agent API calls while its experimental flag is disabled. */
|
|
70
74
|
assertEnabled(id: string): void {
|
|
75
|
+
if (this.disposed) throw new Error("Thread agent is shutting down");
|
|
71
76
|
if (!this.isEnabled(id))
|
|
72
77
|
throw new Error(
|
|
73
78
|
"Thread agent is disabled; enable [experimental] thread_agent in config.toml",
|
|
@@ -76,7 +81,7 @@ export class ThreadAgentManager {
|
|
|
76
81
|
|
|
77
82
|
/** Read a transcript without starting an agent or contacting a model. */
|
|
78
83
|
get(id: string): ThreadAgentState {
|
|
79
|
-
this.
|
|
84
|
+
this.assertReadable(id);
|
|
80
85
|
const cached = this.states.get(id);
|
|
81
86
|
|
|
82
87
|
if (cached) return structuredClone(cached);
|
|
@@ -100,13 +105,16 @@ export class ThreadAgentManager {
|
|
|
100
105
|
state.phase.kind === "permission" ||
|
|
101
106
|
state.submissions?.some((entry) => entry.status === "queued" || entry.status === "running")
|
|
102
107
|
) {
|
|
103
|
-
state.phase =
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
state.phase =
|
|
109
|
+
state.harness?.id === "pi"
|
|
110
|
+
? { kind: "idle" }
|
|
111
|
+
: {
|
|
112
|
+
kind: "failed",
|
|
113
|
+
error: "Agent was interrupted by a daemon restart. Send a message to resume.",
|
|
114
|
+
};
|
|
107
115
|
for (const submission of state.submissions ?? []) {
|
|
108
116
|
if (submission.status === "running" || submission.status === "queued")
|
|
109
|
-
submission.status = "failed";
|
|
117
|
+
submission.status = state.harness?.id === "pi" ? "queued" : "failed";
|
|
110
118
|
}
|
|
111
119
|
for (const tool of state.tools) {
|
|
112
120
|
if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
|
|
@@ -132,7 +140,7 @@ export class ThreadAgentManager {
|
|
|
132
140
|
}
|
|
133
141
|
if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
|
|
134
142
|
if (thread.status !== "pending") throw new Error("Thread agent review is already resolved");
|
|
135
|
-
assertHarnessIdentity(state, this.
|
|
143
|
+
assertHarnessIdentity(state, this.adapter(state));
|
|
136
144
|
const before = structuredClone(state);
|
|
137
145
|
|
|
138
146
|
try {
|
|
@@ -140,46 +148,12 @@ export class ThreadAgentManager {
|
|
|
140
148
|
state.submissions ??= [];
|
|
141
149
|
const submissions = state.submissions;
|
|
142
150
|
const revision = thread.revisions.at(-1)?.revision ?? 1;
|
|
143
|
-
const enqueue = (input: Omit<AgentSubmission, "id" | "status">): void =>
|
|
144
|
-
|
|
145
|
-
state.messages.length +
|
|
146
|
-
submissions.filter((entry) => entry.status === "queued" || entry.status === "running")
|
|
147
|
-
.length +
|
|
148
|
-
2 >
|
|
149
|
-
128
|
|
150
|
-
)
|
|
151
|
-
throw new Error("Thread agent prototype reached its 128-message limit");
|
|
152
|
-
if (
|
|
153
|
-
Buffer.byteLength(input.prompt) > 320_000 ||
|
|
154
|
-
Buffer.byteLength(input.quote ?? "") > 320_000
|
|
155
|
-
)
|
|
156
|
-
throw new Error("Thread agent input exceeds 320 KiB");
|
|
157
|
-
const used = state.messages.reduce(
|
|
158
|
-
(total, message) => total + Buffer.byteLength(message.text),
|
|
159
|
-
0,
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
if (used + Buffer.byteLength(input.prompt) > 2 * 1024 * 1024)
|
|
163
|
-
throw new Error("Thread agent input exceeds transcript limit");
|
|
164
|
-
const submission: AgentSubmission = {
|
|
165
|
-
...input,
|
|
166
|
-
id: randomUUID(),
|
|
167
|
-
status: stepAgentSubmission("draft", "submit", true),
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
submission.commentId ??= submission.id;
|
|
171
|
-
submissions.push(submission);
|
|
172
|
-
state.messages.push({
|
|
173
|
-
id: submission.id,
|
|
174
|
-
submissionId: submission.id,
|
|
175
|
-
role: "user",
|
|
176
|
-
text: submission.prompt,
|
|
177
|
-
complete: true,
|
|
178
|
-
revision,
|
|
179
|
-
});
|
|
180
|
-
};
|
|
151
|
+
const enqueue = (input: Omit<AgentSubmission, "id" | "status">): void =>
|
|
152
|
+
this.enqueue(state, revision, input, promptMirrorIdentity(params));
|
|
181
153
|
|
|
182
|
-
if (params.
|
|
154
|
+
if (params.commentId) {
|
|
155
|
+
this.enqueueComment(state, thread, params.commentId, enqueue, params);
|
|
156
|
+
} else if (params.retry) {
|
|
183
157
|
const submission = submissions.find((entry) => entry.id === params.retry);
|
|
184
158
|
|
|
185
159
|
if (!submission || submission.status !== "failed")
|
|
@@ -189,41 +163,10 @@ export class ThreadAgentManager {
|
|
|
189
163
|
} else {
|
|
190
164
|
const accepted = new Set(submissions.map((entry) => entry.commentId));
|
|
191
165
|
|
|
192
|
-
for (const annotation of thread.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
(entry) => entry.id === (annotation.replyTo ?? annotation.id),
|
|
197
|
-
) ?? annotation;
|
|
198
|
-
const discussion = thread.annotations.filter(
|
|
199
|
-
(entry) => entry.id === root.id || entry.replyTo === root.id,
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
enqueue({ commentId: annotation.id, prompt: annotation.body, quote: root.anchor.quote });
|
|
203
|
-
// The discussion snapshot is passed as context, while the mirror shows only the new comment.
|
|
204
|
-
const submission = submissions.at(-1)!;
|
|
205
|
-
|
|
206
|
-
submission.context = JSON.stringify({
|
|
207
|
-
target: root.target,
|
|
208
|
-
discussion: discussion.map((entry) => ({ id: entry.id, body: entry.body })),
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
for (const comment of state.comments.filter((entry) => !entry.sent)) {
|
|
212
|
-
enqueue({
|
|
213
|
-
commentId: comment.id,
|
|
214
|
-
prompt: comment.body,
|
|
215
|
-
quote: comment.anchor.quote,
|
|
216
|
-
messageId: comment.messageId,
|
|
217
|
-
});
|
|
218
|
-
const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
|
|
219
|
-
|
|
220
|
-
submissions.at(-1)!.context = JSON.stringify({
|
|
221
|
-
discussion: [root, ...state.comments.filter((entry) => entry.replyTo === root.id)].map(
|
|
222
|
-
(entry) => ({ id: entry.id, body: entry.body }),
|
|
223
|
-
),
|
|
224
|
-
});
|
|
225
|
-
comment.sent = true;
|
|
226
|
-
}
|
|
166
|
+
for (const annotation of pendingAgentAnnotations(thread, accepted, params.inputOnly))
|
|
167
|
+
this.enqueueComment(state, thread, annotation.id, enqueue);
|
|
168
|
+
for (const comment of state.comments.filter((entry) => !params.inputOnly && !entry.sent))
|
|
169
|
+
this.enqueueComment(state, thread, comment.id, enqueue);
|
|
227
170
|
if (params.text.trim()) enqueue({ prompt: params.text.trim(), quote: params.context });
|
|
228
171
|
}
|
|
229
172
|
recordPromptOperation(state, before, params);
|
|
@@ -267,6 +210,7 @@ export class ThreadAgentManager {
|
|
|
267
210
|
|
|
268
211
|
if (current.phase.kind !== "running" && current.phase.kind !== "permission")
|
|
269
212
|
active.turn = stepAgentTurn(active.turn, "finished");
|
|
213
|
+
this.drain(state);
|
|
270
214
|
|
|
271
215
|
return structuredClone(state);
|
|
272
216
|
}
|
|
@@ -278,12 +222,79 @@ export class ThreadAgentManager {
|
|
|
278
222
|
return structuredClone(state);
|
|
279
223
|
}
|
|
280
224
|
|
|
225
|
+
private enqueueComment(
|
|
226
|
+
state: ThreadAgentState,
|
|
227
|
+
thread: Thread,
|
|
228
|
+
commentId: string,
|
|
229
|
+
enqueue: (input: Omit<AgentSubmission, "id" | "status">) => void,
|
|
230
|
+
frozen?: AgentPromptRequest,
|
|
231
|
+
): void {
|
|
232
|
+
const annotation = thread.annotations.find((entry) => entry.id === commentId);
|
|
233
|
+
const comment = state.comments.find((entry) => entry.id === commentId);
|
|
234
|
+
const input = annotation ?? comment;
|
|
235
|
+
|
|
236
|
+
if (!input || state.submissions?.some((submission) => submission.commentId === commentId))
|
|
237
|
+
throw new Error("Shared agent comment is unavailable or already sent");
|
|
238
|
+
enqueue({
|
|
239
|
+
commentId,
|
|
240
|
+
prompt: frozen?.text ?? input.body,
|
|
241
|
+
quote: frozen?.context ?? input.anchor.quote,
|
|
242
|
+
messageId: comment?.messageId,
|
|
243
|
+
context: frozen?.discussion ?? commentDiscussion(state, thread, commentId),
|
|
244
|
+
});
|
|
245
|
+
if (comment) comment.sent = true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private enqueue(
|
|
249
|
+
state: ThreadAgentState,
|
|
250
|
+
revision: number,
|
|
251
|
+
input: Omit<AgentSubmission, "id" | "status">,
|
|
252
|
+
operationId?: string,
|
|
253
|
+
): void {
|
|
254
|
+
const submissions = state.submissions!;
|
|
255
|
+
|
|
256
|
+
if (
|
|
257
|
+
state.messages.length +
|
|
258
|
+
submissions.filter((entry) => entry.status === "queued" || entry.status === "running")
|
|
259
|
+
.length +
|
|
260
|
+
2 >
|
|
261
|
+
128
|
|
262
|
+
)
|
|
263
|
+
throw new Error("Thread agent prototype reached its 128-message limit");
|
|
264
|
+
if (Buffer.byteLength(input.prompt) > 320_000 || Buffer.byteLength(input.quote ?? "") > 320_000)
|
|
265
|
+
throw new Error("Thread agent input exceeds 320 KiB");
|
|
266
|
+
const used = state.messages.reduce(
|
|
267
|
+
(total, message) => total + Buffer.byteLength(message.text),
|
|
268
|
+
0,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
if (used + Buffer.byteLength(input.prompt) > 2 * 1024 * 1024)
|
|
272
|
+
throw new Error("Thread agent input exceeds transcript limit");
|
|
273
|
+
const submission: AgentSubmission = {
|
|
274
|
+
...input,
|
|
275
|
+
id: operationId ?? randomUUID(),
|
|
276
|
+
status: stepAgentSubmission("draft", "submit", true),
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
submission.commentId ??= submission.id;
|
|
280
|
+
submissions.push(submission);
|
|
281
|
+
state.messages.push({
|
|
282
|
+
id: submission.id,
|
|
283
|
+
submissionId: submission.id,
|
|
284
|
+
role: "user",
|
|
285
|
+
text: submission.prompt,
|
|
286
|
+
complete: true,
|
|
287
|
+
revision,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
281
291
|
private drain(state: ThreadAgentState): void {
|
|
282
292
|
if (
|
|
283
293
|
this.states.get(state.threadId) !== state ||
|
|
284
294
|
state.phase.kind === "running" ||
|
|
285
295
|
state.phase.kind === "permission" ||
|
|
286
|
-
this.disposed
|
|
296
|
+
this.disposed ||
|
|
297
|
+
this.retiring.has(state.threadId)
|
|
287
298
|
)
|
|
288
299
|
return;
|
|
289
300
|
const submission = state.submissions?.find((entry) => entry.status === "queued");
|
|
@@ -295,21 +306,28 @@ export class ThreadAgentManager {
|
|
|
295
306
|
state.phase = { kind: "running" };
|
|
296
307
|
const thread = this.options.getThread(state.threadId);
|
|
297
308
|
const revision = thread.revisions.at(-1)?.revision ?? 1;
|
|
298
|
-
const prompt =
|
|
309
|
+
const prompt =
|
|
310
|
+
submission.harnessPrompt ??
|
|
311
|
+
`You are assisting cueloop Thread ${thread.id}. Answer this input individually. Use cueloop tools when the user asks to reply to a comment or return feedback to the main session. A reply to a Changes comment must use its original annotation ID.
|
|
299
312
|
Reviewed artifact:
|
|
300
313
|
${thread.artifact.content.slice(0, 32768)}
|
|
301
314
|
Conversation:
|
|
302
|
-
${
|
|
303
|
-
.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
315
|
+
${
|
|
316
|
+
this.adapter(state).id === "pi"
|
|
317
|
+
? "Continued in the durable conversation."
|
|
318
|
+
: state.messages
|
|
319
|
+
.filter((entry) => entry.submissionId !== submission.id)
|
|
320
|
+
.map((entry) => `${entry.role}: ${entry.text}`)
|
|
321
|
+
.join("\n")
|
|
322
|
+
.slice(-65536)
|
|
323
|
+
}
|
|
307
324
|
Comment ID: ${submission.commentId ?? "none"}
|
|
308
325
|
Quote: ${submission.quote ?? ""}
|
|
309
326
|
Discussion context: ${submission.context ?? submission.messageId ?? ""}
|
|
310
327
|
Input: ${submission.prompt}`;
|
|
311
328
|
|
|
312
329
|
try {
|
|
330
|
+
submission.harnessPrompt = prompt;
|
|
313
331
|
this.save(state);
|
|
314
332
|
} catch (error) {
|
|
315
333
|
// Acceptance is already durable; a retry can resume the queue before any harness request.
|
|
@@ -423,22 +441,53 @@ Input: ${submission.prompt}`;
|
|
|
423
441
|
}
|
|
424
442
|
|
|
425
443
|
/** Daemon shutdown owns process cleanup; closing a client does not stop a turn. */
|
|
426
|
-
dispose(): void {
|
|
444
|
+
dispose(): Promise<void> {
|
|
427
445
|
this.disposed = true;
|
|
428
|
-
|
|
429
|
-
|
|
446
|
+
this.closing ??= Promise.all(
|
|
447
|
+
[...this.active.values()]
|
|
448
|
+
.map((active) => active.connection.close())
|
|
449
|
+
.concat([...this.retiring.values()]),
|
|
450
|
+
).then(() => {
|
|
451
|
+
this.active.clear();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
return this.closing;
|
|
430
455
|
}
|
|
431
456
|
|
|
432
457
|
/** Deleting a Thread also stops its process and removes its private transcript. */
|
|
433
|
-
remove(id: string): void {
|
|
434
|
-
const
|
|
458
|
+
async remove(id: string): Promise<void> {
|
|
459
|
+
const state = this.mutable(id);
|
|
435
460
|
|
|
436
|
-
this.
|
|
461
|
+
this.deleting.add(id);
|
|
437
462
|
this.states.delete(id);
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
463
|
+
try {
|
|
464
|
+
await this.retiring.get(id);
|
|
465
|
+
const active = this.active.get(id);
|
|
466
|
+
|
|
467
|
+
this.active.delete(id);
|
|
468
|
+
active?.connection.cancel();
|
|
469
|
+
await active?.connection.close();
|
|
470
|
+
if (state.harness?.sessionId) await this.adapter(state).remove?.(state.harness.sessionId);
|
|
471
|
+
rmSync(this.path(id), { force: true });
|
|
472
|
+
rmSync(this.path(id) + ".tmp", { force: true });
|
|
473
|
+
rmSync(join(this.directory, `${encodeURIComponent(id)}.diagnostics.ndjson`), { force: true });
|
|
474
|
+
} finally {
|
|
475
|
+
this.deleting.delete(id);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private adapter(state: ThreadAgentState): AgentHarnessAdapter {
|
|
480
|
+
const adapter =
|
|
481
|
+
(state.harness && this.options.adapters?.[state.harness.id]) ?? this.options.adapter;
|
|
482
|
+
|
|
483
|
+
if (!adapter) throw new Error("Thread agent harness is not configured");
|
|
484
|
+
|
|
485
|
+
return adapter;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private assertReadable(id: string): void {
|
|
489
|
+
if (this.deleting.has(id)) throw new Error("Thread agent is being deleted");
|
|
490
|
+
this.options.getThread(id);
|
|
442
491
|
}
|
|
443
492
|
|
|
444
493
|
private mutable(id: string): ThreadAgentState {
|
|
@@ -478,6 +527,7 @@ Input: ${submission.prompt}`;
|
|
|
478
527
|
|
|
479
528
|
try {
|
|
480
529
|
active = await this.connect(state, thread, turnId, revision);
|
|
530
|
+
if (this.states.get(state.threadId) !== state) return;
|
|
481
531
|
active.turnId = turnId;
|
|
482
532
|
active.revision = revision;
|
|
483
533
|
// Cancellation during initialization must not submit a new model request.
|
|
@@ -489,8 +539,9 @@ Input: ${submission.prompt}`;
|
|
|
489
539
|
|
|
490
540
|
return;
|
|
491
541
|
}
|
|
492
|
-
const result = await active.connection.prompt(prompt);
|
|
542
|
+
const result = await active.connection.prompt(prompt, turnId);
|
|
493
543
|
|
|
544
|
+
if (this.states.get(state.threadId) !== state) return;
|
|
494
545
|
finalizeAgentMessages(state, messageStart, result.outcome);
|
|
495
546
|
if (submission && result.outcome === "cancelled") submission.cancelled = true;
|
|
496
547
|
if (submission)
|
|
@@ -513,21 +564,41 @@ Input: ${submission.prompt}`;
|
|
|
513
564
|
kind: "failed",
|
|
514
565
|
error: error instanceof Error ? error.message : "Thread agent request failed",
|
|
515
566
|
};
|
|
516
|
-
this.
|
|
517
|
-
|
|
518
|
-
if (!this.disposed) {
|
|
567
|
+
await this.retire(state.threadId, active);
|
|
568
|
+
if (!this.disposed && this.states.get(state.threadId) === state) {
|
|
519
569
|
this.save(state);
|
|
520
570
|
this.drain(state);
|
|
521
571
|
}
|
|
522
572
|
}
|
|
523
573
|
}
|
|
524
574
|
|
|
575
|
+
private retire(id: string, active = this.active.get(id)): Promise<void> {
|
|
576
|
+
const existing = this.retiring.get(id);
|
|
577
|
+
|
|
578
|
+
if (existing) return existing;
|
|
579
|
+
this.active.delete(id);
|
|
580
|
+
const closing = Promise.resolve()
|
|
581
|
+
.then(() => active?.connection.close())
|
|
582
|
+
.catch((error) => console.error("[agent cleanup]", error))
|
|
583
|
+
.finally(() => {
|
|
584
|
+
this.retiring.delete(id);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
this.retiring.set(id, closing);
|
|
588
|
+
|
|
589
|
+
return closing;
|
|
590
|
+
}
|
|
591
|
+
|
|
525
592
|
private async connect(
|
|
526
593
|
state: ThreadAgentState,
|
|
527
594
|
thread: Thread,
|
|
528
595
|
turnId: string,
|
|
529
596
|
revision: number,
|
|
530
597
|
): Promise<ActiveAgent> {
|
|
598
|
+
const retiring = this.retiring.get(state.threadId);
|
|
599
|
+
|
|
600
|
+
if (retiring) await retiring;
|
|
601
|
+
if (this.disposed) throw new Error("Thread agent is shutting down");
|
|
531
602
|
const pending = this.initializing.get(state.threadId);
|
|
532
603
|
|
|
533
604
|
if (pending) return pending;
|
|
@@ -550,7 +621,8 @@ Input: ${submission.prompt}`;
|
|
|
550
621
|
let active = this.active.get(state.threadId);
|
|
551
622
|
|
|
552
623
|
if (!active) {
|
|
553
|
-
const
|
|
624
|
+
const adapter = this.adapter(state);
|
|
625
|
+
const connection = adapter.connect({
|
|
554
626
|
cwd: thread.artifact.meta.cwd ?? thread.workspace.repoRoot,
|
|
555
627
|
sessionId: state.harness?.sessionId,
|
|
556
628
|
tools: this.options.tools?.(thread),
|
|
@@ -569,9 +641,10 @@ Input: ${submission.prompt}`;
|
|
|
569
641
|
this.active.set(state.threadId, active);
|
|
570
642
|
const sessionId = await connection.start();
|
|
571
643
|
|
|
644
|
+
if (this.states.get(state.threadId) !== state) throw new Error("Thread agent was removed");
|
|
572
645
|
state.harness = {
|
|
573
|
-
id:
|
|
574
|
-
label:
|
|
646
|
+
id: adapter.id,
|
|
647
|
+
label: adapter.label,
|
|
575
648
|
sessionId,
|
|
576
649
|
};
|
|
577
650
|
active.turn = stepAgentTurn(active.turn, "ready");
|
|
@@ -646,7 +719,17 @@ Input: ${submission.prompt}`;
|
|
|
646
719
|
Math.min(320_000 - Buffer.byteLength(message.text), 2 * 1024 * 1024 - used),
|
|
647
720
|
);
|
|
648
721
|
|
|
649
|
-
message.text
|
|
722
|
+
message.text = update.replace
|
|
723
|
+
? Buffer.from(update.text)
|
|
724
|
+
.subarray(
|
|
725
|
+
0,
|
|
726
|
+
Math.max(
|
|
727
|
+
0,
|
|
728
|
+
Math.min(320_000, 2 * 1024 * 1024 - used + Buffer.byteLength(message.text)),
|
|
729
|
+
),
|
|
730
|
+
)
|
|
731
|
+
.toString("utf8")
|
|
732
|
+
: message.text + Buffer.from(update.text).subarray(0, budget).toString("utf8");
|
|
650
733
|
} else {
|
|
651
734
|
let tool = state.tools.find((tool) => tool.id === update.id && tool.turnId === active.turnId);
|
|
652
735
|
|
|
@@ -711,3 +794,42 @@ function assertHarnessIdentity(state: ThreadAgentState, adapter: AgentHarnessAda
|
|
|
711
794
|
if (state.harness && state.harness.id !== adapter.id)
|
|
712
795
|
throw new Error("Thread agent belongs to a different harness; create a new Thread");
|
|
713
796
|
}
|
|
797
|
+
|
|
798
|
+
function promptMirrorIdentity(params: AgentPromptRequest): string | undefined {
|
|
799
|
+
return params.inputOnly ? params.operationId : undefined;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function pendingAgentAnnotations(
|
|
803
|
+
thread: Thread,
|
|
804
|
+
accepted: Set<string | undefined>,
|
|
805
|
+
inputOnly?: boolean,
|
|
806
|
+
) {
|
|
807
|
+
return inputOnly
|
|
808
|
+
? []
|
|
809
|
+
: thread.annotations.filter((annotation) => isPendingAgentInput(annotation, accepted));
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function commentDiscussion(state: ThreadAgentState, thread: Thread, commentId: string): string {
|
|
813
|
+
const annotation = thread.annotations.find((entry) => entry.id === commentId);
|
|
814
|
+
|
|
815
|
+
if (annotation) {
|
|
816
|
+
const root =
|
|
817
|
+
thread.annotations.find((entry) => entry.id === (annotation.replyTo ?? annotation.id)) ??
|
|
818
|
+
annotation;
|
|
819
|
+
|
|
820
|
+
return JSON.stringify({
|
|
821
|
+
target: root.target,
|
|
822
|
+
discussion: thread.annotations
|
|
823
|
+
.filter((entry) => entry.id === root.id || entry.replyTo === root.id)
|
|
824
|
+
.map((entry) => ({ id: entry.id, body: entry.body })),
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
const comment = agentCommentRoot(state, commentId)!;
|
|
828
|
+
const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
|
|
829
|
+
|
|
830
|
+
return JSON.stringify({
|
|
831
|
+
discussion: [root, ...state.comments.filter((entry) => entry.replyTo === root.id)].map(
|
|
832
|
+
(entry) => ({ id: entry.id, body: entry.body }),
|
|
833
|
+
),
|
|
834
|
+
});
|
|
835
|
+
}
|
package/src/validate.ts
CHANGED
|
@@ -188,6 +188,7 @@ export const ShareLinkSchema = v.object({
|
|
|
188
188
|
name: v.optional(v.string()),
|
|
189
189
|
requireAuth: v.boolean(),
|
|
190
190
|
allowlist: v.array(NonEmpty),
|
|
191
|
+
agentEnabled: v.optional(v.boolean()),
|
|
191
192
|
owner: v.optional(v.string()),
|
|
192
193
|
shareBranch: v.optional(v.string()),
|
|
193
194
|
} satisfies EntriesOf<ShareLink>);
|
|
@@ -222,6 +223,9 @@ export const Params = {
|
|
|
222
223
|
text: v.pipe(v.string(), v.maxLength(65_536)),
|
|
223
224
|
context: v.optional(v.pipe(v.string(), v.maxLength(32_768))),
|
|
224
225
|
retry: v.optional(NonEmpty),
|
|
226
|
+
commentId: v.optional(NonEmpty),
|
|
227
|
+
inputOnly: v.optional(v.boolean()),
|
|
228
|
+
discussion: v.optional(v.pipe(v.string(), v.maxLength(320_000))),
|
|
225
229
|
operationId: v.optional(v.pipe(NonEmpty, v.maxLength(128))),
|
|
226
230
|
}),
|
|
227
231
|
"agent.reply": v.object({
|