@cueloop/daemon 0.1.0-alpha.100 → 0.1.0-alpha.102
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 +235 -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.102",
|
|
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.102",
|
|
48
|
+
"@cueloop/schema": "0.1.0-alpha.102",
|
|
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,19 @@ 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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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,
|
|
151
|
+
const enqueue = (input: Omit<AgentSubmission, "id" | "status">): void =>
|
|
152
|
+
this.enqueue(state, revision, input, promptMirrorIdentity(params));
|
|
153
|
+
|
|
154
|
+
if (params.commentId) {
|
|
155
|
+
// Shared requests carry an accepted discussion snapshot; local requests read the saved note.
|
|
156
|
+
this.enqueueComment(
|
|
157
|
+
state,
|
|
158
|
+
thread,
|
|
159
|
+
params.commentId,
|
|
160
|
+
enqueue,
|
|
161
|
+
params.discussion === undefined ? undefined : params,
|
|
160
162
|
);
|
|
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
|
-
};
|
|
181
|
-
|
|
182
|
-
if (params.retry) {
|
|
163
|
+
} else if (params.retry) {
|
|
183
164
|
const submission = submissions.find((entry) => entry.id === params.retry);
|
|
184
165
|
|
|
185
166
|
if (!submission || submission.status !== "failed")
|
|
@@ -189,41 +170,10 @@ export class ThreadAgentManager {
|
|
|
189
170
|
} else {
|
|
190
171
|
const accepted = new Set(submissions.map((entry) => entry.commentId));
|
|
191
172
|
|
|
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
|
-
}
|
|
173
|
+
for (const annotation of pendingAgentAnnotations(thread, accepted, params.inputOnly))
|
|
174
|
+
this.enqueueComment(state, thread, annotation.id, enqueue);
|
|
175
|
+
for (const comment of state.comments.filter((entry) => !params.inputOnly && !entry.sent))
|
|
176
|
+
this.enqueueComment(state, thread, comment.id, enqueue);
|
|
227
177
|
if (params.text.trim()) enqueue({ prompt: params.text.trim(), quote: params.context });
|
|
228
178
|
}
|
|
229
179
|
recordPromptOperation(state, before, params);
|
|
@@ -267,6 +217,7 @@ export class ThreadAgentManager {
|
|
|
267
217
|
|
|
268
218
|
if (current.phase.kind !== "running" && current.phase.kind !== "permission")
|
|
269
219
|
active.turn = stepAgentTurn(active.turn, "finished");
|
|
220
|
+
this.drain(state);
|
|
270
221
|
|
|
271
222
|
return structuredClone(state);
|
|
272
223
|
}
|
|
@@ -278,12 +229,79 @@ export class ThreadAgentManager {
|
|
|
278
229
|
return structuredClone(state);
|
|
279
230
|
}
|
|
280
231
|
|
|
232
|
+
private enqueueComment(
|
|
233
|
+
state: ThreadAgentState,
|
|
234
|
+
thread: Thread,
|
|
235
|
+
commentId: string,
|
|
236
|
+
enqueue: (input: Omit<AgentSubmission, "id" | "status">) => void,
|
|
237
|
+
frozen?: AgentPromptRequest,
|
|
238
|
+
): void {
|
|
239
|
+
const annotation = thread.annotations.find((entry) => entry.id === commentId);
|
|
240
|
+
const comment = state.comments.find((entry) => entry.id === commentId);
|
|
241
|
+
const input = annotation ?? comment;
|
|
242
|
+
|
|
243
|
+
if (!input || state.submissions?.some((submission) => submission.commentId === commentId))
|
|
244
|
+
throw new Error("Shared agent comment is unavailable or already sent");
|
|
245
|
+
enqueue({
|
|
246
|
+
commentId,
|
|
247
|
+
prompt: frozen?.text ?? input.body,
|
|
248
|
+
quote: frozen?.context ?? input.anchor.quote,
|
|
249
|
+
messageId: comment?.messageId,
|
|
250
|
+
context: frozen?.discussion ?? commentDiscussion(state, thread, commentId),
|
|
251
|
+
});
|
|
252
|
+
if (comment) comment.sent = true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private enqueue(
|
|
256
|
+
state: ThreadAgentState,
|
|
257
|
+
revision: number,
|
|
258
|
+
input: Omit<AgentSubmission, "id" | "status">,
|
|
259
|
+
operationId?: string,
|
|
260
|
+
): void {
|
|
261
|
+
const submissions = state.submissions!;
|
|
262
|
+
|
|
263
|
+
if (
|
|
264
|
+
state.messages.length +
|
|
265
|
+
submissions.filter((entry) => entry.status === "queued" || entry.status === "running")
|
|
266
|
+
.length +
|
|
267
|
+
2 >
|
|
268
|
+
128
|
|
269
|
+
)
|
|
270
|
+
throw new Error("Thread agent prototype reached its 128-message limit");
|
|
271
|
+
if (Buffer.byteLength(input.prompt) > 320_000 || Buffer.byteLength(input.quote ?? "") > 320_000)
|
|
272
|
+
throw new Error("Thread agent input exceeds 320 KiB");
|
|
273
|
+
const used = state.messages.reduce(
|
|
274
|
+
(total, message) => total + Buffer.byteLength(message.text),
|
|
275
|
+
0,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
if (used + Buffer.byteLength(input.prompt) > 2 * 1024 * 1024)
|
|
279
|
+
throw new Error("Thread agent input exceeds transcript limit");
|
|
280
|
+
const submission: AgentSubmission = {
|
|
281
|
+
...input,
|
|
282
|
+
id: operationId ?? randomUUID(),
|
|
283
|
+
status: stepAgentSubmission("draft", "submit", true),
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
submission.commentId ??= submission.id;
|
|
287
|
+
submissions.push(submission);
|
|
288
|
+
state.messages.push({
|
|
289
|
+
id: submission.id,
|
|
290
|
+
submissionId: submission.id,
|
|
291
|
+
role: "user",
|
|
292
|
+
text: submission.prompt,
|
|
293
|
+
complete: true,
|
|
294
|
+
revision,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
281
298
|
private drain(state: ThreadAgentState): void {
|
|
282
299
|
if (
|
|
283
300
|
this.states.get(state.threadId) !== state ||
|
|
284
301
|
state.phase.kind === "running" ||
|
|
285
302
|
state.phase.kind === "permission" ||
|
|
286
|
-
this.disposed
|
|
303
|
+
this.disposed ||
|
|
304
|
+
this.retiring.has(state.threadId)
|
|
287
305
|
)
|
|
288
306
|
return;
|
|
289
307
|
const submission = state.submissions?.find((entry) => entry.status === "queued");
|
|
@@ -295,21 +313,28 @@ export class ThreadAgentManager {
|
|
|
295
313
|
state.phase = { kind: "running" };
|
|
296
314
|
const thread = this.options.getThread(state.threadId);
|
|
297
315
|
const revision = thread.revisions.at(-1)?.revision ?? 1;
|
|
298
|
-
const prompt =
|
|
316
|
+
const prompt =
|
|
317
|
+
submission.harnessPrompt ??
|
|
318
|
+
`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
319
|
Reviewed artifact:
|
|
300
320
|
${thread.artifact.content.slice(0, 32768)}
|
|
301
321
|
Conversation:
|
|
302
|
-
${
|
|
303
|
-
.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
322
|
+
${
|
|
323
|
+
this.adapter(state).id === "pi"
|
|
324
|
+
? "Continued in the durable conversation."
|
|
325
|
+
: state.messages
|
|
326
|
+
.filter((entry) => entry.submissionId !== submission.id)
|
|
327
|
+
.map((entry) => `${entry.role}: ${entry.text}`)
|
|
328
|
+
.join("\n")
|
|
329
|
+
.slice(-65536)
|
|
330
|
+
}
|
|
307
331
|
Comment ID: ${submission.commentId ?? "none"}
|
|
308
332
|
Quote: ${submission.quote ?? ""}
|
|
309
333
|
Discussion context: ${submission.context ?? submission.messageId ?? ""}
|
|
310
334
|
Input: ${submission.prompt}`;
|
|
311
335
|
|
|
312
336
|
try {
|
|
337
|
+
submission.harnessPrompt = prompt;
|
|
313
338
|
this.save(state);
|
|
314
339
|
} catch (error) {
|
|
315
340
|
// Acceptance is already durable; a retry can resume the queue before any harness request.
|
|
@@ -423,22 +448,53 @@ Input: ${submission.prompt}`;
|
|
|
423
448
|
}
|
|
424
449
|
|
|
425
450
|
/** Daemon shutdown owns process cleanup; closing a client does not stop a turn. */
|
|
426
|
-
dispose(): void {
|
|
451
|
+
dispose(): Promise<void> {
|
|
427
452
|
this.disposed = true;
|
|
428
|
-
|
|
429
|
-
|
|
453
|
+
this.closing ??= Promise.all(
|
|
454
|
+
[...this.active.values()]
|
|
455
|
+
.map((active) => active.connection.close())
|
|
456
|
+
.concat([...this.retiring.values()]),
|
|
457
|
+
).then(() => {
|
|
458
|
+
this.active.clear();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
return this.closing;
|
|
430
462
|
}
|
|
431
463
|
|
|
432
464
|
/** Deleting a Thread also stops its process and removes its private transcript. */
|
|
433
|
-
remove(id: string): void {
|
|
434
|
-
const
|
|
465
|
+
async remove(id: string): Promise<void> {
|
|
466
|
+
const state = this.mutable(id);
|
|
435
467
|
|
|
436
|
-
this.
|
|
468
|
+
this.deleting.add(id);
|
|
437
469
|
this.states.delete(id);
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
470
|
+
try {
|
|
471
|
+
await this.retiring.get(id);
|
|
472
|
+
const active = this.active.get(id);
|
|
473
|
+
|
|
474
|
+
this.active.delete(id);
|
|
475
|
+
active?.connection.cancel();
|
|
476
|
+
await active?.connection.close();
|
|
477
|
+
if (state.harness?.sessionId) await this.adapter(state).remove?.(state.harness.sessionId);
|
|
478
|
+
rmSync(this.path(id), { force: true });
|
|
479
|
+
rmSync(this.path(id) + ".tmp", { force: true });
|
|
480
|
+
rmSync(join(this.directory, `${encodeURIComponent(id)}.diagnostics.ndjson`), { force: true });
|
|
481
|
+
} finally {
|
|
482
|
+
this.deleting.delete(id);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private adapter(state: ThreadAgentState): AgentHarnessAdapter {
|
|
487
|
+
const adapter =
|
|
488
|
+
(state.harness && this.options.adapters?.[state.harness.id]) ?? this.options.adapter;
|
|
489
|
+
|
|
490
|
+
if (!adapter) throw new Error("Thread agent harness is not configured");
|
|
491
|
+
|
|
492
|
+
return adapter;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private assertReadable(id: string): void {
|
|
496
|
+
if (this.deleting.has(id)) throw new Error("Thread agent is being deleted");
|
|
497
|
+
this.options.getThread(id);
|
|
442
498
|
}
|
|
443
499
|
|
|
444
500
|
private mutable(id: string): ThreadAgentState {
|
|
@@ -478,6 +534,7 @@ Input: ${submission.prompt}`;
|
|
|
478
534
|
|
|
479
535
|
try {
|
|
480
536
|
active = await this.connect(state, thread, turnId, revision);
|
|
537
|
+
if (this.states.get(state.threadId) !== state) return;
|
|
481
538
|
active.turnId = turnId;
|
|
482
539
|
active.revision = revision;
|
|
483
540
|
// Cancellation during initialization must not submit a new model request.
|
|
@@ -489,8 +546,9 @@ Input: ${submission.prompt}`;
|
|
|
489
546
|
|
|
490
547
|
return;
|
|
491
548
|
}
|
|
492
|
-
const result = await active.connection.prompt(prompt);
|
|
549
|
+
const result = await active.connection.prompt(prompt, turnId);
|
|
493
550
|
|
|
551
|
+
if (this.states.get(state.threadId) !== state) return;
|
|
494
552
|
finalizeAgentMessages(state, messageStart, result.outcome);
|
|
495
553
|
if (submission && result.outcome === "cancelled") submission.cancelled = true;
|
|
496
554
|
if (submission)
|
|
@@ -513,21 +571,41 @@ Input: ${submission.prompt}`;
|
|
|
513
571
|
kind: "failed",
|
|
514
572
|
error: error instanceof Error ? error.message : "Thread agent request failed",
|
|
515
573
|
};
|
|
516
|
-
this.
|
|
517
|
-
|
|
518
|
-
if (!this.disposed) {
|
|
574
|
+
await this.retire(state.threadId, active);
|
|
575
|
+
if (!this.disposed && this.states.get(state.threadId) === state) {
|
|
519
576
|
this.save(state);
|
|
520
577
|
this.drain(state);
|
|
521
578
|
}
|
|
522
579
|
}
|
|
523
580
|
}
|
|
524
581
|
|
|
582
|
+
private retire(id: string, active = this.active.get(id)): Promise<void> {
|
|
583
|
+
const existing = this.retiring.get(id);
|
|
584
|
+
|
|
585
|
+
if (existing) return existing;
|
|
586
|
+
this.active.delete(id);
|
|
587
|
+
const closing = Promise.resolve()
|
|
588
|
+
.then(() => active?.connection.close())
|
|
589
|
+
.catch((error) => console.error("[agent cleanup]", error))
|
|
590
|
+
.finally(() => {
|
|
591
|
+
this.retiring.delete(id);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
this.retiring.set(id, closing);
|
|
595
|
+
|
|
596
|
+
return closing;
|
|
597
|
+
}
|
|
598
|
+
|
|
525
599
|
private async connect(
|
|
526
600
|
state: ThreadAgentState,
|
|
527
601
|
thread: Thread,
|
|
528
602
|
turnId: string,
|
|
529
603
|
revision: number,
|
|
530
604
|
): Promise<ActiveAgent> {
|
|
605
|
+
const retiring = this.retiring.get(state.threadId);
|
|
606
|
+
|
|
607
|
+
if (retiring) await retiring;
|
|
608
|
+
if (this.disposed) throw new Error("Thread agent is shutting down");
|
|
531
609
|
const pending = this.initializing.get(state.threadId);
|
|
532
610
|
|
|
533
611
|
if (pending) return pending;
|
|
@@ -550,7 +628,8 @@ Input: ${submission.prompt}`;
|
|
|
550
628
|
let active = this.active.get(state.threadId);
|
|
551
629
|
|
|
552
630
|
if (!active) {
|
|
553
|
-
const
|
|
631
|
+
const adapter = this.adapter(state);
|
|
632
|
+
const connection = adapter.connect({
|
|
554
633
|
cwd: thread.artifact.meta.cwd ?? thread.workspace.repoRoot,
|
|
555
634
|
sessionId: state.harness?.sessionId,
|
|
556
635
|
tools: this.options.tools?.(thread),
|
|
@@ -569,9 +648,10 @@ Input: ${submission.prompt}`;
|
|
|
569
648
|
this.active.set(state.threadId, active);
|
|
570
649
|
const sessionId = await connection.start();
|
|
571
650
|
|
|
651
|
+
if (this.states.get(state.threadId) !== state) throw new Error("Thread agent was removed");
|
|
572
652
|
state.harness = {
|
|
573
|
-
id:
|
|
574
|
-
label:
|
|
653
|
+
id: adapter.id,
|
|
654
|
+
label: adapter.label,
|
|
575
655
|
sessionId,
|
|
576
656
|
};
|
|
577
657
|
active.turn = stepAgentTurn(active.turn, "ready");
|
|
@@ -646,7 +726,17 @@ Input: ${submission.prompt}`;
|
|
|
646
726
|
Math.min(320_000 - Buffer.byteLength(message.text), 2 * 1024 * 1024 - used),
|
|
647
727
|
);
|
|
648
728
|
|
|
649
|
-
message.text
|
|
729
|
+
message.text = update.replace
|
|
730
|
+
? Buffer.from(update.text)
|
|
731
|
+
.subarray(
|
|
732
|
+
0,
|
|
733
|
+
Math.max(
|
|
734
|
+
0,
|
|
735
|
+
Math.min(320_000, 2 * 1024 * 1024 - used + Buffer.byteLength(message.text)),
|
|
736
|
+
),
|
|
737
|
+
)
|
|
738
|
+
.toString("utf8")
|
|
739
|
+
: message.text + Buffer.from(update.text).subarray(0, budget).toString("utf8");
|
|
650
740
|
} else {
|
|
651
741
|
let tool = state.tools.find((tool) => tool.id === update.id && tool.turnId === active.turnId);
|
|
652
742
|
|
|
@@ -711,3 +801,42 @@ function assertHarnessIdentity(state: ThreadAgentState, adapter: AgentHarnessAda
|
|
|
711
801
|
if (state.harness && state.harness.id !== adapter.id)
|
|
712
802
|
throw new Error("Thread agent belongs to a different harness; create a new Thread");
|
|
713
803
|
}
|
|
804
|
+
|
|
805
|
+
function promptMirrorIdentity(params: AgentPromptRequest): string | undefined {
|
|
806
|
+
return params.inputOnly ? params.operationId : undefined;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function pendingAgentAnnotations(
|
|
810
|
+
thread: Thread,
|
|
811
|
+
accepted: Set<string | undefined>,
|
|
812
|
+
inputOnly?: boolean,
|
|
813
|
+
) {
|
|
814
|
+
return inputOnly
|
|
815
|
+
? []
|
|
816
|
+
: thread.annotations.filter((annotation) => isPendingAgentInput(annotation, accepted));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function commentDiscussion(state: ThreadAgentState, thread: Thread, commentId: string): string {
|
|
820
|
+
const annotation = thread.annotations.find((entry) => entry.id === commentId);
|
|
821
|
+
|
|
822
|
+
if (annotation) {
|
|
823
|
+
const root =
|
|
824
|
+
thread.annotations.find((entry) => entry.id === (annotation.replyTo ?? annotation.id)) ??
|
|
825
|
+
annotation;
|
|
826
|
+
|
|
827
|
+
return JSON.stringify({
|
|
828
|
+
target: root.target,
|
|
829
|
+
discussion: thread.annotations
|
|
830
|
+
.filter((entry) => entry.id === root.id || entry.replyTo === root.id)
|
|
831
|
+
.map((entry) => ({ id: entry.id, body: entry.body })),
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
const comment = agentCommentRoot(state, commentId)!;
|
|
835
|
+
const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
|
|
836
|
+
|
|
837
|
+
return JSON.stringify({
|
|
838
|
+
discussion: [root, ...state.comments.filter((entry) => entry.replyTo === root.id)].map(
|
|
839
|
+
(entry) => ({ id: entry.id, body: entry.body }),
|
|
840
|
+
),
|
|
841
|
+
});
|
|
842
|
+
}
|
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({
|