@cueloop/daemon 0.1.0-alpha.96 → 0.1.0-alpha.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/agent-tools.ts +98 -0
- package/src/agent-turn.test.ts +24 -0
- package/src/agent-turn.ts +34 -0
- package/src/capabilities.ts +6 -0
- package/src/client.test.ts +13 -0
- package/src/client.ts +50 -3
- package/src/harness-diagnostics.test.ts +26 -0
- package/src/harness-diagnostics.ts +30 -0
- package/src/server.ts +151 -2
- package/src/thread-agent-validation.ts +108 -0
- package/src/thread-agent.test.ts +307 -0
- package/src/thread-agent.ts +666 -0
- package/src/validate.ts +16 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
import type { ThreadAgentState } from "@cueloop/schema";
|
|
3
|
+
|
|
4
|
+
const AgentAnchorSchema = v.object({
|
|
5
|
+
quote: v.pipe(v.string(), v.minLength(1), v.maxLength(32_768)),
|
|
6
|
+
prefix: v.string(),
|
|
7
|
+
suffix: v.string(),
|
|
8
|
+
blockIndex: v.number(),
|
|
9
|
+
start: v.number(),
|
|
10
|
+
end: v.number(),
|
|
11
|
+
endBlockIndex: v.optional(v.number()),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/** Validate agent comments at the socket and persisted transcript boundaries. */
|
|
15
|
+
export const AgentCommentSchema = v.object({
|
|
16
|
+
id: v.string(),
|
|
17
|
+
messageId: v.string(),
|
|
18
|
+
anchor: AgentAnchorSchema,
|
|
19
|
+
body: v.pipe(v.string(), v.minLength(1), v.maxLength(32_768)),
|
|
20
|
+
sent: v.boolean(),
|
|
21
|
+
replyTo: v.optional(v.string()),
|
|
22
|
+
author: v.optional(v.string()),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Validate persisted agent transcripts independently of submitted artifacts. */
|
|
26
|
+
const StoredAgentSchema = v.object({
|
|
27
|
+
threadId: v.string(),
|
|
28
|
+
fxSessionId: v.optional(v.string()),
|
|
29
|
+
harness: v.optional(
|
|
30
|
+
v.object({ id: v.string(), label: v.string(), sessionId: v.optional(v.string()) }),
|
|
31
|
+
),
|
|
32
|
+
phase: v.variant("kind", [
|
|
33
|
+
v.object({ kind: v.literal("idle") }),
|
|
34
|
+
v.object({ kind: v.literal("running") }),
|
|
35
|
+
v.object({ kind: v.literal("failed"), error: v.string() }),
|
|
36
|
+
v.object({
|
|
37
|
+
kind: v.literal("permission"),
|
|
38
|
+
permission: v.object({
|
|
39
|
+
id: v.string(),
|
|
40
|
+
title: v.string(),
|
|
41
|
+
options: v.array(
|
|
42
|
+
v.object({
|
|
43
|
+
optionId: v.string(),
|
|
44
|
+
name: v.string(),
|
|
45
|
+
kind: v.string(),
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
}),
|
|
49
|
+
}),
|
|
50
|
+
]),
|
|
51
|
+
messages: v.array(
|
|
52
|
+
v.object({
|
|
53
|
+
id: v.string(),
|
|
54
|
+
role: v.picklist(["user", "agent"]),
|
|
55
|
+
text: v.string(),
|
|
56
|
+
complete: v.boolean(),
|
|
57
|
+
revision: v.number(),
|
|
58
|
+
submissionId: v.optional(v.string()),
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
tools: v.array(
|
|
62
|
+
v.object({
|
|
63
|
+
id: v.string(),
|
|
64
|
+
turnId: v.string(),
|
|
65
|
+
title: v.string(),
|
|
66
|
+
kind: v.string(),
|
|
67
|
+
status: v.picklist(["pending", "in_progress", "completed", "failed", "cancelled"]),
|
|
68
|
+
output: v.string(),
|
|
69
|
+
locations: v.array(v.object({ path: v.string(), line: v.optional(v.number()) })),
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
comments: v.array(AgentCommentSchema),
|
|
73
|
+
submissions: v.optional(
|
|
74
|
+
v.array(
|
|
75
|
+
v.object({
|
|
76
|
+
id: v.string(),
|
|
77
|
+
commentId: v.optional(v.string()),
|
|
78
|
+
messageId: v.optional(v.string()),
|
|
79
|
+
prompt: v.string(),
|
|
80
|
+
quote: v.optional(v.string()),
|
|
81
|
+
context: v.optional(v.string()),
|
|
82
|
+
status: v.picklist(["queued", "running", "completed", "failed"]),
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
configOptions: v.optional(
|
|
87
|
+
v.array(
|
|
88
|
+
v.object({
|
|
89
|
+
id: v.string(),
|
|
90
|
+
name: v.string(),
|
|
91
|
+
category: v.optional(v.string()),
|
|
92
|
+
currentValue: v.string(),
|
|
93
|
+
options: v.array(v.object({ value: v.string(), name: v.string() })),
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/** Read the first fx prototype's records without discarding their session identity. */
|
|
100
|
+
export const ThreadAgentSchema: v.GenericSchema<ThreadAgentState> = v.pipe(
|
|
101
|
+
StoredAgentSchema,
|
|
102
|
+
v.transform(({ fxSessionId, ...state }) => ({
|
|
103
|
+
...state,
|
|
104
|
+
harness:
|
|
105
|
+
state.harness ??
|
|
106
|
+
(fxSessionId ? { id: "fx", label: "fx", sessionId: fxSessionId } : undefined),
|
|
107
|
+
})),
|
|
108
|
+
);
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { SCHEMA_VERSION, type Thread, type AgentHarnessAdapter } from "@cueloop/schema";
|
|
6
|
+
import { ThreadAgentManager } from "./thread-agent";
|
|
7
|
+
|
|
8
|
+
function createTestThread(home: string): Thread {
|
|
9
|
+
return {
|
|
10
|
+
schemaVersion: SCHEMA_VERSION,
|
|
11
|
+
id: "adapter-test",
|
|
12
|
+
workspace: { repoRoot: home, branch: "main" },
|
|
13
|
+
artifact: { type: "plan", content: "Review the retry", meta: {} },
|
|
14
|
+
revisions: [],
|
|
15
|
+
annotations: [],
|
|
16
|
+
message: null,
|
|
17
|
+
status: "pending",
|
|
18
|
+
createdAt: "2026-10-04",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("a non-ACP harness streams and resumes through the same Thread manager", async () => {
|
|
23
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-harness-"));
|
|
24
|
+
const thread = createTestThread(home);
|
|
25
|
+
const sessions: (string | undefined)[] = [];
|
|
26
|
+
const adapter: AgentHarnessAdapter = {
|
|
27
|
+
id: "test-native",
|
|
28
|
+
label: "Native agent",
|
|
29
|
+
connect(options) {
|
|
30
|
+
sessions.push(options.sessionId);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
start: async () => options.sessionId ?? "native-session",
|
|
34
|
+
prompt: async () => {
|
|
35
|
+
options.onEvent({
|
|
36
|
+
kind: "tool",
|
|
37
|
+
id: "read",
|
|
38
|
+
title: "Read retry",
|
|
39
|
+
toolKind: "read",
|
|
40
|
+
status: "completed",
|
|
41
|
+
});
|
|
42
|
+
options.onEvent({ kind: "message", id: crypto.randomUUID(), text: "Native answer" });
|
|
43
|
+
|
|
44
|
+
return { outcome: "completed" as const };
|
|
45
|
+
},
|
|
46
|
+
cancel() {},
|
|
47
|
+
permission() {},
|
|
48
|
+
close() {},
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
let manager = new ThreadAgentManager({
|
|
53
|
+
home,
|
|
54
|
+
enabled: true,
|
|
55
|
+
adapter,
|
|
56
|
+
getThread: () => thread,
|
|
57
|
+
onChange() {},
|
|
58
|
+
});
|
|
59
|
+
const finish = async () => {
|
|
60
|
+
for (let attempt = 0; manager.get(thread.id).phase.kind !== "idle" && attempt < 100; attempt++)
|
|
61
|
+
await Bun.sleep(1);
|
|
62
|
+
expect(manager.get(thread.id).phase).toEqual({ kind: "idle" });
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
manager.prompt({ id: thread.id, text: "Explain" });
|
|
67
|
+
await finish();
|
|
68
|
+
expect(manager.get(thread.id).harness).toEqual({
|
|
69
|
+
id: "test-native",
|
|
70
|
+
label: "Native agent",
|
|
71
|
+
sessionId: "native-session",
|
|
72
|
+
});
|
|
73
|
+
expect(manager.get(thread.id).messages[1]?.text).toBe("Native answer");
|
|
74
|
+
const promptDiscussion = { id: manager.get(thread.id).submissions![0]!.id };
|
|
75
|
+
expect(manager.isReadOnly(thread.id, promptDiscussion.id)).toBe(true);
|
|
76
|
+
const promptReply = manager.reply(thread.id, promptDiscussion.id, "The prompt is clear");
|
|
77
|
+
expect(promptReply.comments.at(-1)?.replyTo).toBe(promptDiscussion.id);
|
|
78
|
+
const answer = manager.get(thread.id).messages[1]!;
|
|
79
|
+
manager.comment({
|
|
80
|
+
id: thread.id,
|
|
81
|
+
comment: {
|
|
82
|
+
id: "answer-question",
|
|
83
|
+
messageId: answer.id,
|
|
84
|
+
anchor: { quote: "Native", prefix: "", suffix: " answer", blockIndex: 0, start: 0, end: 6 },
|
|
85
|
+
body: "Explain this",
|
|
86
|
+
sent: false,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const replied = manager.reply(thread.id, "answer-question", "This is the explanation");
|
|
90
|
+
expect(replied.comments.at(-1)).toMatchObject({
|
|
91
|
+
replyTo: "answer-question",
|
|
92
|
+
messageId: answer.id,
|
|
93
|
+
sent: true,
|
|
94
|
+
author: "embedded-agent",
|
|
95
|
+
});
|
|
96
|
+
manager.comment({
|
|
97
|
+
id: thread.id,
|
|
98
|
+
comment: {
|
|
99
|
+
...replied.comments.find((entry) => entry.id === "answer-question")!,
|
|
100
|
+
id: "answer-followup",
|
|
101
|
+
replyTo: "answer-question",
|
|
102
|
+
body: "Why?",
|
|
103
|
+
sent: false,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
manager.prompt({ id: thread.id, text: "" });
|
|
107
|
+
expect(
|
|
108
|
+
manager.get(thread.id).submissions?.find((entry) => entry.commentId === "answer-followup")
|
|
109
|
+
?.context,
|
|
110
|
+
).toContain("This is the explanation");
|
|
111
|
+
await finish();
|
|
112
|
+
manager.dispose();
|
|
113
|
+
manager = new ThreadAgentManager({
|
|
114
|
+
home,
|
|
115
|
+
enabled: true,
|
|
116
|
+
adapter,
|
|
117
|
+
getThread: () => thread,
|
|
118
|
+
onChange() {},
|
|
119
|
+
});
|
|
120
|
+
manager.prompt({ id: thread.id, text: "Continue" });
|
|
121
|
+
await finish();
|
|
122
|
+
expect(sessions).toEqual([undefined, "native-session"]);
|
|
123
|
+
const other = new ThreadAgentManager({
|
|
124
|
+
home,
|
|
125
|
+
enabled: true,
|
|
126
|
+
adapter: { ...adapter, id: "another-agent" },
|
|
127
|
+
getThread: () => thread,
|
|
128
|
+
onChange() {},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(() => other.prompt({ id: thread.id, text: "Mix sessions" })).toThrow(
|
|
132
|
+
"different harness",
|
|
133
|
+
);
|
|
134
|
+
other.dispose();
|
|
135
|
+
} finally {
|
|
136
|
+
manager.dispose();
|
|
137
|
+
rmSync(home, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("the first prototype's fx records migrate without losing session identity", () => {
|
|
142
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-harness-migrate-"));
|
|
143
|
+
const thread = createTestThread(home);
|
|
144
|
+
const path = join(home, "thread-agents");
|
|
145
|
+
|
|
146
|
+
mkdirSync(path);
|
|
147
|
+
writeFileSync(
|
|
148
|
+
join(path, `${thread.id}.json`),
|
|
149
|
+
JSON.stringify({
|
|
150
|
+
threadId: thread.id,
|
|
151
|
+
fxSessionId: "saved-fx-session",
|
|
152
|
+
phase: { kind: "idle" },
|
|
153
|
+
messages: [],
|
|
154
|
+
tools: [],
|
|
155
|
+
comments: [],
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
const manager = new ThreadAgentManager({
|
|
159
|
+
home,
|
|
160
|
+
enabled: false,
|
|
161
|
+
getThread: () => thread,
|
|
162
|
+
onChange() {},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
expect(manager.get(thread.id).harness).toEqual({
|
|
167
|
+
id: "fx",
|
|
168
|
+
label: "fx",
|
|
169
|
+
sessionId: "saved-fx-session",
|
|
170
|
+
});
|
|
171
|
+
} finally {
|
|
172
|
+
manager.dispose();
|
|
173
|
+
rmSync(home, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("empty input is a no-op; queued comments freeze once and retries reuse their mirrors", async () => {
|
|
178
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-agent-queue-"));
|
|
179
|
+
const thread = createTestThread(home);
|
|
180
|
+
const prompts: string[] = [];
|
|
181
|
+
const finishes: ((outcome: "completed" | "cancelled") => void)[] = [];
|
|
182
|
+
const adapter: AgentHarnessAdapter = {
|
|
183
|
+
id: "test",
|
|
184
|
+
label: "Test",
|
|
185
|
+
connect(options) {
|
|
186
|
+
return {
|
|
187
|
+
start: async () => "session",
|
|
188
|
+
prompt: (text) => {
|
|
189
|
+
prompts.push(text);
|
|
190
|
+
options.onEvent({
|
|
191
|
+
kind: "message",
|
|
192
|
+
id: `answer-${prompts.length}`,
|
|
193
|
+
text: `Reply ${prompts.length}`,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return new Promise((resolve) => finishes.push((outcome) => resolve({ outcome })));
|
|
197
|
+
},
|
|
198
|
+
cancel() {
|
|
199
|
+
finishes.at(-1)?.("cancelled");
|
|
200
|
+
},
|
|
201
|
+
permission() {},
|
|
202
|
+
close() {},
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
const manager = new ThreadAgentManager({
|
|
207
|
+
home,
|
|
208
|
+
enabled: true,
|
|
209
|
+
adapter,
|
|
210
|
+
getThread: () => thread,
|
|
211
|
+
onChange() {},
|
|
212
|
+
});
|
|
213
|
+
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
manager.prompt({ id: thread.id, text: "" });
|
|
217
|
+
expect(prompts).toEqual([]);
|
|
218
|
+
expect(manager.get(thread.id).messages).toEqual([]);
|
|
219
|
+
thread.annotations.push({
|
|
220
|
+
id: "first",
|
|
221
|
+
kind: "comment",
|
|
222
|
+
body: "Explain first",
|
|
223
|
+
anchor: {
|
|
224
|
+
quote: "Review",
|
|
225
|
+
prefix: "",
|
|
226
|
+
suffix: " the retry",
|
|
227
|
+
blockIndex: 0,
|
|
228
|
+
start: 0,
|
|
229
|
+
end: 6,
|
|
230
|
+
},
|
|
231
|
+
createdAt: thread.createdAt,
|
|
232
|
+
});
|
|
233
|
+
manager.prompt({ id: thread.id, text: "" });
|
|
234
|
+
await tick();
|
|
235
|
+
expect(manager.isReadOnly(thread.id, "first")).toBe(true);
|
|
236
|
+
thread.annotations.push({ ...thread.annotations[0]!, id: "second", body: "Explain second" });
|
|
237
|
+
manager.prompt({ id: thread.id, text: "" });
|
|
238
|
+
expect(prompts).toHaveLength(1);
|
|
239
|
+
expect(manager.get(thread.id).submissions?.map((entry) => entry.status)).toEqual([
|
|
240
|
+
"running",
|
|
241
|
+
"queued",
|
|
242
|
+
]);
|
|
243
|
+
finishes[0]!("completed");
|
|
244
|
+
await tick();
|
|
245
|
+
expect(prompts).toHaveLength(2);
|
|
246
|
+
expect(prompts[1]).toContain("Input: Explain second");
|
|
247
|
+
expect(prompts[1]).toContain("Reply 1");
|
|
248
|
+
finishes[1]!("cancelled");
|
|
249
|
+
await tick();
|
|
250
|
+
const failed = manager.get(thread.id).submissions![1]!;
|
|
251
|
+
const mirrors = manager.get(thread.id).messages.filter((entry) => entry.role === "user");
|
|
252
|
+
|
|
253
|
+
manager.prompt({ id: thread.id, text: "", retry: failed.id });
|
|
254
|
+
await tick();
|
|
255
|
+
expect(manager.get(thread.id).messages.filter((entry) => entry.role === "user")).toEqual(
|
|
256
|
+
mirrors,
|
|
257
|
+
);
|
|
258
|
+
expect(manager.isReadOnly(thread.id, "second")).toBe(true);
|
|
259
|
+
finishes[2]!("completed");
|
|
260
|
+
await tick();
|
|
261
|
+
expect(manager.get(thread.id).submissions?.map((entry) => entry.status)).toEqual([
|
|
262
|
+
"completed",
|
|
263
|
+
"completed",
|
|
264
|
+
]);
|
|
265
|
+
manager.prompt({ id: thread.id, text: "" });
|
|
266
|
+
expect(prompts).toHaveLength(3);
|
|
267
|
+
} finally {
|
|
268
|
+
manager.dispose();
|
|
269
|
+
rmSync(home, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("rejected first batches leave originals editable and create no queued turn", () => {
|
|
274
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-agent-rejected-"));
|
|
275
|
+
const thread = createTestThread(home);
|
|
276
|
+
const manager = new ThreadAgentManager({
|
|
277
|
+
home,
|
|
278
|
+
enabled: true,
|
|
279
|
+
adapter: {
|
|
280
|
+
id: "test",
|
|
281
|
+
label: "Test",
|
|
282
|
+
connect() {
|
|
283
|
+
throw new Error("A rejected batch must not start");
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
getThread: () => thread,
|
|
287
|
+
onChange() {},
|
|
288
|
+
});
|
|
289
|
+
const original = {
|
|
290
|
+
id: "valid",
|
|
291
|
+
kind: "comment",
|
|
292
|
+
body: "Explain",
|
|
293
|
+
anchor: { quote: "Review", prefix: "", suffix: " the retry", blockIndex: 0, start: 0, end: 6 },
|
|
294
|
+
createdAt: thread.createdAt,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
thread.annotations.push(original, { ...original, id: "oversized", body: "x".repeat(320001) });
|
|
299
|
+
expect(() => manager.prompt({ id: thread.id, text: "" })).toThrow("320 KiB");
|
|
300
|
+
expect(manager.get(thread.id).messages).toEqual([]);
|
|
301
|
+
expect(manager.get(thread.id).submissions).toBeUndefined();
|
|
302
|
+
expect(manager.isReadOnly(thread.id, "valid")).toBe(false);
|
|
303
|
+
} finally {
|
|
304
|
+
manager.dispose();
|
|
305
|
+
rmSync(home, { recursive: true, force: true });
|
|
306
|
+
}
|
|
307
|
+
});
|