@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,666 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, existsSync, writeFileSync, renameSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import * as v from "valibot";
|
|
5
|
+
import {
|
|
6
|
+
parseBlocks,
|
|
7
|
+
routeHarnessOutput,
|
|
8
|
+
agentCommentRoot,
|
|
9
|
+
stepAgentSubmission,
|
|
10
|
+
isAgentNote,
|
|
11
|
+
type AgentSubmission,
|
|
12
|
+
type AgentHarnessTools,
|
|
13
|
+
resolveAnchor,
|
|
14
|
+
type AgentComment,
|
|
15
|
+
type Thread,
|
|
16
|
+
type ThreadAgentState,
|
|
17
|
+
type AgentHarnessAdapter,
|
|
18
|
+
type AgentHarnessConnection,
|
|
19
|
+
type AgentHarnessEvent,
|
|
20
|
+
type AgentHarnessResult,
|
|
21
|
+
} from "@cueloop/schema";
|
|
22
|
+
import { writeHarnessDiagnostic } from "./harness-diagnostics";
|
|
23
|
+
import { stepAgentTurn, agentTurnCancelled, type AgentTurn } from "./agent-turn";
|
|
24
|
+
import { ThreadAgentSchema } from "./thread-agent-validation";
|
|
25
|
+
|
|
26
|
+
/** Prototype configuration is chosen by the daemon, never an incoming socket caller. */
|
|
27
|
+
export interface ThreadAgentOptions {
|
|
28
|
+
home: string;
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
enabledForThread?: (thread: Thread) => boolean;
|
|
31
|
+
adapter?: AgentHarnessAdapter;
|
|
32
|
+
getThread: (id: string) => Thread;
|
|
33
|
+
onChange: (id: string) => void;
|
|
34
|
+
/** Each harness receives tools scoped to the Thread that opened its connection. */
|
|
35
|
+
tools?: (thread: Thread) => AgentHarnessTools;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ActiveAgent {
|
|
39
|
+
connection: AgentHarnessConnection;
|
|
40
|
+
turn: AgentTurn;
|
|
41
|
+
turnId: string;
|
|
42
|
+
revision: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Own harness processes and durable agent transcripts without mutating reviewed artifacts. */
|
|
46
|
+
export class ThreadAgentManager {
|
|
47
|
+
private states = new Map<string, ThreadAgentState>();
|
|
48
|
+
private active = new Map<string, ActiveAgent>();
|
|
49
|
+
private readonly directory: string;
|
|
50
|
+
private disposed = false;
|
|
51
|
+
private initializing = new Map<string, Promise<ActiveAgent>>();
|
|
52
|
+
|
|
53
|
+
constructor(private readonly options: ThreadAgentOptions) {
|
|
54
|
+
this.directory = join(options.home, "thread-agents");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Experimental policy is resolved against the Thread workspace by the local CLI. */
|
|
58
|
+
isEnabled(id: string): boolean {
|
|
59
|
+
return (
|
|
60
|
+
this.options.enabled && (this.options.enabledForThread?.(this.options.getThread(id)) ?? true)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Refuse agent API calls while its experimental flag is disabled. */
|
|
65
|
+
assertEnabled(id: string): void {
|
|
66
|
+
if (!this.isEnabled(id))
|
|
67
|
+
throw new Error(
|
|
68
|
+
"Thread agent is disabled; enable [experimental] thread_agent in config.toml",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read a transcript without starting an agent or contacting a model. */
|
|
73
|
+
get(id: string): ThreadAgentState {
|
|
74
|
+
this.options.getThread(id);
|
|
75
|
+
const cached = this.states.get(id);
|
|
76
|
+
|
|
77
|
+
if (cached) return structuredClone(cached);
|
|
78
|
+
const path = this.path(id);
|
|
79
|
+
const state = existsSync(path)
|
|
80
|
+
? v.parse(ThreadAgentSchema, JSON.parse(readFileSync(path, "utf8")))
|
|
81
|
+
: {
|
|
82
|
+
threadId: id,
|
|
83
|
+
harness: this.options.adapter
|
|
84
|
+
? { id: this.options.adapter.id, label: this.options.adapter.label }
|
|
85
|
+
: undefined,
|
|
86
|
+
phase: { kind: "idle" as const },
|
|
87
|
+
messages: [],
|
|
88
|
+
tools: [],
|
|
89
|
+
comments: [],
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (state.threadId !== id) throw new Error("Thread agent record has the wrong thread identity");
|
|
93
|
+
if (state.phase.kind === "running" || state.phase.kind === "permission") {
|
|
94
|
+
state.phase = {
|
|
95
|
+
kind: "failed",
|
|
96
|
+
error: "Agent was interrupted by a daemon restart. Send a message to resume.",
|
|
97
|
+
};
|
|
98
|
+
for (const submission of state.submissions ?? []) {
|
|
99
|
+
if (submission.status === "running" || submission.status === "queued")
|
|
100
|
+
submission.status = "failed";
|
|
101
|
+
}
|
|
102
|
+
for (const tool of state.tools) {
|
|
103
|
+
if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
this.states.set(id, state);
|
|
107
|
+
|
|
108
|
+
return structuredClone(state);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Freeze each pending comment once and queue an individual answer at the Thread tail. */
|
|
112
|
+
prompt(params: { id: string; text: string; context?: string; retry?: string }): ThreadAgentState {
|
|
113
|
+
this.assertEnabled(params.id);
|
|
114
|
+
if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
|
|
115
|
+
const state = this.mutable(params.id);
|
|
116
|
+
const thread = this.options.getThread(params.id);
|
|
117
|
+
|
|
118
|
+
if (thread.status !== "pending") throw new Error("Thread agent review is already resolved");
|
|
119
|
+
assertHarnessIdentity(state, this.options.adapter);
|
|
120
|
+
const before = structuredClone(state);
|
|
121
|
+
try {
|
|
122
|
+
state.harness ??= { id: this.options.adapter.id, label: this.options.adapter.label };
|
|
123
|
+
state.submissions ??= [];
|
|
124
|
+
const submissions = state.submissions;
|
|
125
|
+
const revision = thread.revisions.at(-1)?.revision ?? 1;
|
|
126
|
+
const enqueue = (input: Omit<AgentSubmission, "id" | "status">): void => {
|
|
127
|
+
if (
|
|
128
|
+
state.messages.length +
|
|
129
|
+
submissions.filter((entry) => entry.status === "queued" || entry.status === "running")
|
|
130
|
+
.length +
|
|
131
|
+
2 >
|
|
132
|
+
128
|
|
133
|
+
)
|
|
134
|
+
throw new Error("Thread agent prototype reached its 128-message limit");
|
|
135
|
+
if (
|
|
136
|
+
Buffer.byteLength(input.prompt) > 320_000 ||
|
|
137
|
+
Buffer.byteLength(input.quote ?? "") > 320_000
|
|
138
|
+
)
|
|
139
|
+
throw new Error("Thread agent input exceeds 320 KiB");
|
|
140
|
+
const used = state.messages.reduce(
|
|
141
|
+
(total, message) => total + Buffer.byteLength(message.text),
|
|
142
|
+
0,
|
|
143
|
+
);
|
|
144
|
+
if (used + Buffer.byteLength(input.prompt) > 2 * 1024 * 1024)
|
|
145
|
+
throw new Error("Thread agent input exceeds transcript limit");
|
|
146
|
+
const submission: AgentSubmission = {
|
|
147
|
+
...input,
|
|
148
|
+
id: randomUUID(),
|
|
149
|
+
status: stepAgentSubmission("draft", "submit", true),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
submission.commentId ??= submission.id;
|
|
153
|
+
submissions.push(submission);
|
|
154
|
+
state.messages.push({
|
|
155
|
+
id: submission.id,
|
|
156
|
+
submissionId: submission.id,
|
|
157
|
+
role: "user",
|
|
158
|
+
text: submission.prompt,
|
|
159
|
+
complete: true,
|
|
160
|
+
revision,
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
if (params.retry) {
|
|
165
|
+
const submission = submissions.find((entry) => entry.id === params.retry);
|
|
166
|
+
|
|
167
|
+
if (!submission || submission.status !== "failed")
|
|
168
|
+
throw new Error("Thread agent retry requires a failed submission");
|
|
169
|
+
submission.status = stepAgentSubmission(submission.status, "retry", true);
|
|
170
|
+
} else {
|
|
171
|
+
const accepted = new Set(submissions.map((entry) => entry.commentId));
|
|
172
|
+
for (const annotation of thread.annotations) {
|
|
173
|
+
if (!isPendingAgentInput(annotation, accepted)) continue;
|
|
174
|
+
const root =
|
|
175
|
+
thread.annotations.find(
|
|
176
|
+
(entry) => entry.id === (annotation.replyTo ?? annotation.id),
|
|
177
|
+
) ?? annotation;
|
|
178
|
+
const discussion = thread.annotations.filter(
|
|
179
|
+
(entry) => entry.id === root.id || entry.replyTo === root.id,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
enqueue({ commentId: annotation.id, prompt: annotation.body, quote: root.anchor.quote });
|
|
183
|
+
// The discussion snapshot is passed as context, while the mirror shows only the new comment.
|
|
184
|
+
const submission = submissions.at(-1)!;
|
|
185
|
+
submission.context = JSON.stringify({
|
|
186
|
+
target: root.target,
|
|
187
|
+
discussion: discussion.map((entry) => ({ id: entry.id, body: entry.body })),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
for (const comment of state.comments.filter((entry) => !entry.sent)) {
|
|
191
|
+
enqueue({
|
|
192
|
+
commentId: comment.id,
|
|
193
|
+
prompt: comment.body,
|
|
194
|
+
quote: comment.anchor.quote,
|
|
195
|
+
messageId: comment.messageId,
|
|
196
|
+
});
|
|
197
|
+
const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
|
|
198
|
+
submissions.at(-1)!.context = JSON.stringify({
|
|
199
|
+
discussion: [root, ...state.comments.filter((entry) => entry.replyTo === root.id)].map(
|
|
200
|
+
(entry) => ({ id: entry.id, body: entry.body }),
|
|
201
|
+
),
|
|
202
|
+
});
|
|
203
|
+
comment.sent = true;
|
|
204
|
+
}
|
|
205
|
+
if (params.text.trim()) enqueue({ prompt: params.text.trim(), quote: params.context });
|
|
206
|
+
}
|
|
207
|
+
this.save(state);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
restoreAgentState(state, before);
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
this.drain(state);
|
|
213
|
+
|
|
214
|
+
return structuredClone(state);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The daemon enforces read-only originals even when a socket client tries to edit them. */
|
|
218
|
+
isReadOnly(id: string, annotationId: string): boolean {
|
|
219
|
+
if (!this.isEnabled(id)) return false;
|
|
220
|
+
|
|
221
|
+
return Boolean(this.get(id).submissions?.some((entry) => entry.commentId === annotationId));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Apply harness-advertised choices without submitting a model request. */
|
|
225
|
+
async configure(params: {
|
|
226
|
+
id: string;
|
|
227
|
+
configId?: string;
|
|
228
|
+
value?: string;
|
|
229
|
+
}): Promise<ThreadAgentState> {
|
|
230
|
+
this.assertEnabled(params.id);
|
|
231
|
+
if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
|
|
232
|
+
const state = this.mutable(params.id);
|
|
233
|
+
|
|
234
|
+
if (state.phase.kind === "running" || state.phase.kind === "permission")
|
|
235
|
+
throw new Error("Thread agent configuration waits for the current turn");
|
|
236
|
+
const active = await this.connect(state, this.options.getThread(params.id), "", 1);
|
|
237
|
+
|
|
238
|
+
if (params.configId === undefined || params.value === undefined) {
|
|
239
|
+
const current = this.get(params.id);
|
|
240
|
+
if (current.phase.kind !== "running" && current.phase.kind !== "permission")
|
|
241
|
+
active.turn = stepAgentTurn(active.turn, "finished");
|
|
242
|
+
return structuredClone(state);
|
|
243
|
+
}
|
|
244
|
+
if (!active.connection.configure)
|
|
245
|
+
throw new Error("Thread agent harness does not advertise configuration");
|
|
246
|
+
await active.connection.configure(params.configId, params.value);
|
|
247
|
+
this.save(state);
|
|
248
|
+
|
|
249
|
+
return structuredClone(state);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private drain(state: ThreadAgentState): void {
|
|
253
|
+
if (
|
|
254
|
+
this.states.get(state.threadId) !== state ||
|
|
255
|
+
state.phase.kind === "running" ||
|
|
256
|
+
state.phase.kind === "permission" ||
|
|
257
|
+
this.disposed
|
|
258
|
+
)
|
|
259
|
+
return;
|
|
260
|
+
const submission = state.submissions?.find((entry) => entry.status === "queued");
|
|
261
|
+
|
|
262
|
+
if (!submission) return;
|
|
263
|
+
submission.status = stepAgentSubmission(submission.status, "start", true);
|
|
264
|
+
state.phase = { kind: "running" };
|
|
265
|
+
const thread = this.options.getThread(state.threadId);
|
|
266
|
+
const revision = thread.revisions.at(-1)?.revision ?? 1;
|
|
267
|
+
const prompt = `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.
|
|
268
|
+
Reviewed artifact:
|
|
269
|
+
${thread.artifact.content.slice(0, 32768)}
|
|
270
|
+
Conversation:
|
|
271
|
+
${state.messages
|
|
272
|
+
.filter((entry) => entry.submissionId !== submission.id)
|
|
273
|
+
.map((entry) => `${entry.role}: ${entry.text}`)
|
|
274
|
+
.join("\n")
|
|
275
|
+
.slice(-65536)}
|
|
276
|
+
Comment ID: ${submission.commentId ?? "none"}
|
|
277
|
+
Quote: ${submission.quote ?? ""}
|
|
278
|
+
Discussion context: ${submission.context ?? submission.messageId ?? ""}
|
|
279
|
+
Input: ${submission.prompt}`;
|
|
280
|
+
|
|
281
|
+
this.save(state);
|
|
282
|
+
void this.run(state, thread, submission.id, revision, prompt, [], submission);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Attach quote-primary feedback only to a finalized agent message. */
|
|
286
|
+
comment(params: { id: string; comment: AgentComment }): ThreadAgentState {
|
|
287
|
+
const state = this.mutable(params.id);
|
|
288
|
+
const message = state.messages.find((message) => message.id === params.comment.messageId);
|
|
289
|
+
|
|
290
|
+
if (!message || !message.complete)
|
|
291
|
+
throw new Error("Thread agent comment requires a completed answer");
|
|
292
|
+
if (!resolveAnchor(params.comment.anchor, parseBlocks(message.text)))
|
|
293
|
+
throw new Error("Thread agent comment quote does not resolve");
|
|
294
|
+
const index = state.comments.findIndex((comment) => comment.id === params.comment.id);
|
|
295
|
+
const previous = state.comments[index];
|
|
296
|
+
const root = params.comment.replyTo
|
|
297
|
+
? agentCommentRoot(state, params.comment.replyTo)
|
|
298
|
+
: undefined;
|
|
299
|
+
|
|
300
|
+
if (
|
|
301
|
+
params.comment.replyTo &&
|
|
302
|
+
(!root || root.messageId !== message.id || root.id === params.comment.id)
|
|
303
|
+
)
|
|
304
|
+
throw new Error("Thread agent reply requires a comment on the same answer");
|
|
305
|
+
if (
|
|
306
|
+
this.isReadOnly(params.id, params.comment.id) ||
|
|
307
|
+
(previous && (previous.sent || previous.messageId !== params.comment.messageId))
|
|
308
|
+
)
|
|
309
|
+
throw new Error("Thread agent delivered comments are immutable");
|
|
310
|
+
if (Buffer.byteLength(JSON.stringify(params.comment)) > 32_768)
|
|
311
|
+
throw new Error("Thread agent comment exceeds 32 KiB");
|
|
312
|
+
if (index >= 0) state.comments[index] = { ...params.comment, sent: false };
|
|
313
|
+
else {
|
|
314
|
+
if (state.comments.length >= 128)
|
|
315
|
+
throw new Error("Thread agent prototype reached its comment limit");
|
|
316
|
+
state.comments.push({ ...params.comment, sent: false });
|
|
317
|
+
}
|
|
318
|
+
this.save(state);
|
|
319
|
+
|
|
320
|
+
return structuredClone(state);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Tool replies preserve the answer discussion and are not re-submitted as user input. */
|
|
324
|
+
reply(id: string, commentId: string, body: string): ThreadAgentState {
|
|
325
|
+
const state = this.mutable(id);
|
|
326
|
+
const comment = agentCommentRoot(state, commentId);
|
|
327
|
+
if (!comment) throw new Error("Thread agent reply comment does not exist");
|
|
328
|
+
const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
|
|
329
|
+
if (state.comments.length >= 128)
|
|
330
|
+
throw new Error("Thread agent prototype reached its comment limit");
|
|
331
|
+
state.comments.push({
|
|
332
|
+
...root,
|
|
333
|
+
id: randomUUID(),
|
|
334
|
+
body,
|
|
335
|
+
sent: true,
|
|
336
|
+
author: "embedded-agent",
|
|
337
|
+
replyTo: root.id,
|
|
338
|
+
});
|
|
339
|
+
this.save(state);
|
|
340
|
+
|
|
341
|
+
return structuredClone(state);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Cancel a turn while retaining its output and waiting for the agent's stop response. */
|
|
345
|
+
cancel(id: string): ThreadAgentState {
|
|
346
|
+
const state = this.mutable(id);
|
|
347
|
+
const active = this.active.get(id);
|
|
348
|
+
|
|
349
|
+
if (active && (state.phase.kind === "running" || state.phase.kind === "permission")) {
|
|
350
|
+
active.turn = stepAgentTurn(active.turn, "stop");
|
|
351
|
+
for (const tool of state.tools) {
|
|
352
|
+
if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
|
|
353
|
+
}
|
|
354
|
+
state.phase = { kind: "running" };
|
|
355
|
+
active.connection.cancel();
|
|
356
|
+
this.save(state);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return structuredClone(state);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Answer only the currently waiting permission with one advertised option. */
|
|
363
|
+
permission(params: { id: string; requestId: string; optionId: string }): ThreadAgentState {
|
|
364
|
+
const state = this.mutable(params.id);
|
|
365
|
+
const active = this.active.get(params.id);
|
|
366
|
+
|
|
367
|
+
if (
|
|
368
|
+
!active ||
|
|
369
|
+
state.phase.kind !== "permission" ||
|
|
370
|
+
state.phase.permission.id !== params.requestId
|
|
371
|
+
) {
|
|
372
|
+
throw new Error("Thread agent permission request is no longer pending");
|
|
373
|
+
}
|
|
374
|
+
if (!state.phase.permission.options.some((option) => option.optionId === params.optionId))
|
|
375
|
+
throw new Error("Thread agent permission option was not advertised");
|
|
376
|
+
active.connection.permission(params.requestId, params.optionId);
|
|
377
|
+
state.phase = { kind: "running" };
|
|
378
|
+
this.save(state);
|
|
379
|
+
|
|
380
|
+
return structuredClone(state);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Daemon shutdown owns process cleanup; closing a client does not stop a turn. */
|
|
384
|
+
dispose(): void {
|
|
385
|
+
this.disposed = true;
|
|
386
|
+
for (const active of this.active.values()) active.connection.close();
|
|
387
|
+
this.active.clear();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Deleting a Thread also stops its process and removes its private transcript. */
|
|
391
|
+
remove(id: string): void {
|
|
392
|
+
const active = this.active.get(id);
|
|
393
|
+
|
|
394
|
+
this.active.delete(id);
|
|
395
|
+
this.states.delete(id);
|
|
396
|
+
active?.connection.close();
|
|
397
|
+
rmSync(this.path(id), { force: true });
|
|
398
|
+
rmSync(this.path(id) + ".tmp", { force: true });
|
|
399
|
+
rmSync(join(this.directory, `${encodeURIComponent(id)}.diagnostics.ndjson`), { force: true });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private mutable(id: string): ThreadAgentState {
|
|
403
|
+
this.get(id);
|
|
404
|
+
|
|
405
|
+
return this.states.get(id)!;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private path(id: string): string {
|
|
409
|
+
return join(this.directory, `${encodeURIComponent(id)}.json`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private save(state: ThreadAgentState): void {
|
|
413
|
+
if (this.states.get(state.threadId) !== state) return;
|
|
414
|
+
const data = JSON.stringify(state);
|
|
415
|
+
|
|
416
|
+
if (Buffer.byteLength(data) > 8 * 1024 * 1024)
|
|
417
|
+
throw new Error("Thread agent transcript exceeds 8 MiB");
|
|
418
|
+
mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
419
|
+
writeFileSync(this.path(state.threadId) + ".tmp", data, { mode: 0o600 });
|
|
420
|
+
renameSync(this.path(state.threadId) + ".tmp", this.path(state.threadId));
|
|
421
|
+
this.options.onChange(state.threadId);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private async run(
|
|
425
|
+
state: ThreadAgentState,
|
|
426
|
+
thread: Thread,
|
|
427
|
+
turnId: string,
|
|
428
|
+
revision: number,
|
|
429
|
+
prompt: string,
|
|
430
|
+
comments: AgentComment[],
|
|
431
|
+
submission?: AgentSubmission,
|
|
432
|
+
): Promise<void> {
|
|
433
|
+
let active = this.active.get(state.threadId);
|
|
434
|
+
const messageStart = state.messages.length;
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
active = await this.connect(state, thread, turnId, revision);
|
|
438
|
+
active.turnId = turnId;
|
|
439
|
+
active.revision = revision;
|
|
440
|
+
// Cancellation during initialization must not submit a new model request.
|
|
441
|
+
if (active.turn.kind === "idle") {
|
|
442
|
+
if (submission) submission.status = "failed";
|
|
443
|
+
state.phase = { kind: "idle" };
|
|
444
|
+
this.save(state);
|
|
445
|
+
this.drain(state);
|
|
446
|
+
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const result = await active.connection.prompt(prompt);
|
|
450
|
+
|
|
451
|
+
finalizeAgentMessages(state, messageStart, result.outcome);
|
|
452
|
+
if (submission)
|
|
453
|
+
submission.status = stepAgentSubmission(
|
|
454
|
+
submission.status,
|
|
455
|
+
result.outcome === "completed" ? "complete" : "fail",
|
|
456
|
+
true,
|
|
457
|
+
);
|
|
458
|
+
if (result.outcome === "completed") for (const comment of comments) comment.sent = true;
|
|
459
|
+
for (const tool of state.tools) {
|
|
460
|
+
if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
|
|
461
|
+
}
|
|
462
|
+
state.phase = { kind: "idle" };
|
|
463
|
+
active.turn = stepAgentTurn(active.turn, "finished");
|
|
464
|
+
this.save(state);
|
|
465
|
+
this.drain(state);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (submission) submission.status = "failed";
|
|
468
|
+
state.phase = {
|
|
469
|
+
kind: "failed",
|
|
470
|
+
error: error instanceof Error ? error.message : "Thread agent request failed",
|
|
471
|
+
};
|
|
472
|
+
this.active.delete(state.threadId);
|
|
473
|
+
active?.connection.close();
|
|
474
|
+
if (!this.disposed) {
|
|
475
|
+
this.save(state);
|
|
476
|
+
this.drain(state);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private async connect(
|
|
482
|
+
state: ThreadAgentState,
|
|
483
|
+
thread: Thread,
|
|
484
|
+
turnId: string,
|
|
485
|
+
revision: number,
|
|
486
|
+
): Promise<ActiveAgent> {
|
|
487
|
+
const pending = this.initializing.get(state.threadId);
|
|
488
|
+
if (pending) return pending;
|
|
489
|
+
const promise = this.startConnection(state, thread, turnId, revision);
|
|
490
|
+
|
|
491
|
+
this.initializing.set(state.threadId, promise);
|
|
492
|
+
try {
|
|
493
|
+
return await promise;
|
|
494
|
+
} finally {
|
|
495
|
+
this.initializing.delete(state.threadId);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private async startConnection(
|
|
500
|
+
state: ThreadAgentState,
|
|
501
|
+
thread: Thread,
|
|
502
|
+
turnId: string,
|
|
503
|
+
revision: number,
|
|
504
|
+
): Promise<ActiveAgent> {
|
|
505
|
+
let active = this.active.get(state.threadId);
|
|
506
|
+
|
|
507
|
+
if (!active) {
|
|
508
|
+
const connection = this.options.adapter!.connect({
|
|
509
|
+
cwd: thread.artifact.meta.cwd ?? thread.workspace.repoRoot,
|
|
510
|
+
sessionId: state.harness?.sessionId,
|
|
511
|
+
tools: this.options.tools?.(thread),
|
|
512
|
+
onEvent: (event) => this.receive(state, event),
|
|
513
|
+
onExit: (error) => {
|
|
514
|
+
if (this.active.get(state.threadId)?.connection !== connection) return;
|
|
515
|
+
this.active.delete(state.threadId);
|
|
516
|
+
if (!this.disposed) {
|
|
517
|
+
state.phase = { kind: "failed", error: error.message };
|
|
518
|
+
this.save(state);
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
active = { connection, turn: stepAgentTurn({ kind: "idle" }, "start"), turnId, revision };
|
|
524
|
+
this.active.set(state.threadId, active);
|
|
525
|
+
const sessionId = await connection.start();
|
|
526
|
+
|
|
527
|
+
state.harness = {
|
|
528
|
+
id: this.options.adapter!.id,
|
|
529
|
+
label: this.options.adapter!.label,
|
|
530
|
+
sessionId,
|
|
531
|
+
};
|
|
532
|
+
active.turn = stepAgentTurn(active.turn, "ready");
|
|
533
|
+
this.save(state);
|
|
534
|
+
} else {
|
|
535
|
+
active.turn = stepAgentTurn(stepAgentTurn(active.turn, "start"), "ready");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return active;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private receive(state: ThreadAgentState, event: AgentHarnessEvent): void {
|
|
542
|
+
if (this.states.get(state.threadId) !== state) return;
|
|
543
|
+
const routed = routeHarnessOutput(event);
|
|
544
|
+
|
|
545
|
+
if (routed.destination === "diagnostics") {
|
|
546
|
+
writeHarnessDiagnostic(this.directory, state.threadId, routed.event);
|
|
547
|
+
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
event = routed.event;
|
|
551
|
+
if (event.kind === "config") {
|
|
552
|
+
state.configOptions = event.options;
|
|
553
|
+
this.save(state);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const active = this.active.get(state.threadId);
|
|
557
|
+
|
|
558
|
+
if (!active || active.turn.kind === "starting") return;
|
|
559
|
+
if (event.kind === "permission") {
|
|
560
|
+
if (agentTurnCancelled(active.turn)) {
|
|
561
|
+
active.connection.permission(event.permission.id);
|
|
562
|
+
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
state.phase = { kind: "permission", permission: event.permission };
|
|
566
|
+
this.save(state);
|
|
567
|
+
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
this.applyUpdate(state, active, event);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private applyUpdate(
|
|
574
|
+
state: ThreadAgentState,
|
|
575
|
+
active: ActiveAgent,
|
|
576
|
+
update: Extract<AgentHarnessEvent, { kind: "message" | "tool" }>,
|
|
577
|
+
): void {
|
|
578
|
+
if (update.kind === "message") {
|
|
579
|
+
const messageId = update.id ?? `answer-${active.turnId}`;
|
|
580
|
+
let message = state.messages.find((message) => message.id === messageId);
|
|
581
|
+
|
|
582
|
+
if (!message) {
|
|
583
|
+
message = {
|
|
584
|
+
id: messageId,
|
|
585
|
+
role: "agent",
|
|
586
|
+
text: "",
|
|
587
|
+
complete: false,
|
|
588
|
+
revision: active.revision,
|
|
589
|
+
submissionId: active.turnId,
|
|
590
|
+
};
|
|
591
|
+
state.messages.push(message);
|
|
592
|
+
}
|
|
593
|
+
if (message.complete) return;
|
|
594
|
+
const used = state.messages.reduce(
|
|
595
|
+
(total, entry) => total + Buffer.byteLength(entry.text),
|
|
596
|
+
0,
|
|
597
|
+
);
|
|
598
|
+
const budget = Math.max(
|
|
599
|
+
0,
|
|
600
|
+
Math.min(320_000 - Buffer.byteLength(message.text), 2 * 1024 * 1024 - used),
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
message.text += Buffer.from(update.text).subarray(0, budget).toString("utf8");
|
|
604
|
+
} else {
|
|
605
|
+
let tool = state.tools.find((tool) => tool.id === update.id && tool.turnId === active.turnId);
|
|
606
|
+
|
|
607
|
+
if (!tool) {
|
|
608
|
+
if (state.tools.length >= 128)
|
|
609
|
+
throw new Error("Thread agent reached its tool activity limit");
|
|
610
|
+
tool = {
|
|
611
|
+
id: update.id,
|
|
612
|
+
turnId: active.turnId,
|
|
613
|
+
title: (update.title ?? "Agent tool").slice(0, 256),
|
|
614
|
+
kind: update.toolKind ?? "other",
|
|
615
|
+
status: "pending",
|
|
616
|
+
output: "",
|
|
617
|
+
locations: [],
|
|
618
|
+
};
|
|
619
|
+
state.tools.push(tool);
|
|
620
|
+
}
|
|
621
|
+
if (update.title) tool.title = update.title.slice(0, 256);
|
|
622
|
+
if (update.toolKind) tool.kind = update.toolKind;
|
|
623
|
+
if (update.status)
|
|
624
|
+
tool.status = agentTurnCancelled(active.turn) ? "cancelled" : update.status;
|
|
625
|
+
if (update.locations)
|
|
626
|
+
tool.locations = update.locations
|
|
627
|
+
.slice(0, 8)
|
|
628
|
+
.map((location) => ({ ...location, path: location.path.slice(0, 512) }));
|
|
629
|
+
if (update.output !== undefined) tool.output = update.output.slice(0, 8192);
|
|
630
|
+
}
|
|
631
|
+
this.save(state);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Only this turn can finalize its answers; stopped and historical partials stay incomplete. */
|
|
636
|
+
function finalizeAgentMessages(
|
|
637
|
+
state: ThreadAgentState,
|
|
638
|
+
start: number,
|
|
639
|
+
outcome: AgentHarnessResult["outcome"],
|
|
640
|
+
): void {
|
|
641
|
+
if (outcome !== "completed") return;
|
|
642
|
+
for (const message of state.messages.slice(start)) message.complete = true;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function isPendingAgentInput(
|
|
646
|
+
annotation: Thread["annotations"][number],
|
|
647
|
+
accepted: Set<string | undefined>,
|
|
648
|
+
): boolean {
|
|
649
|
+
return (
|
|
650
|
+
annotation.kind === "comment" &&
|
|
651
|
+
!isAgentNote(annotation) &&
|
|
652
|
+
!annotation.author &&
|
|
653
|
+
!accepted.has(annotation.id)
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function restoreAgentState(state: ThreadAgentState, before: ThreadAgentState): void {
|
|
658
|
+
if (before.submissions === undefined) delete state.submissions;
|
|
659
|
+
if (before.harness === undefined) delete state.harness;
|
|
660
|
+
Object.assign(state, before);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function assertHarnessIdentity(state: ThreadAgentState, adapter: AgentHarnessAdapter): void {
|
|
664
|
+
if (state.harness && state.harness.id !== adapter.id)
|
|
665
|
+
throw new Error("Thread agent belongs to a different harness; create a new Thread");
|
|
666
|
+
}
|
package/src/validate.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
} from "@cueloop/schema";
|
|
42
42
|
import { DaemonError } from "./errors";
|
|
43
43
|
import type { Request } from "./protocol";
|
|
44
|
+
import { AgentCommentSchema } from "./thread-agent-validation";
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Drift guard for every hand-mirrored shape below. v.object strips keys it
|
|
@@ -211,6 +212,21 @@ export const DeliverySchema = v.object({
|
|
|
211
212
|
} satisfies EntriesOf<Delivery>);
|
|
212
213
|
|
|
213
214
|
export const Params = {
|
|
215
|
+
"agent.configure": v.object({
|
|
216
|
+
id: SessionId,
|
|
217
|
+
configId: v.optional(NonEmpty),
|
|
218
|
+
value: v.optional(NonEmpty),
|
|
219
|
+
}),
|
|
220
|
+
"agent.get": v.object({ id: SessionId }),
|
|
221
|
+
"agent.prompt": v.object({
|
|
222
|
+
id: SessionId,
|
|
223
|
+
text: v.pipe(v.string(), v.maxLength(65_536)),
|
|
224
|
+
context: v.optional(v.pipe(v.string(), v.maxLength(32_768))),
|
|
225
|
+
retry: v.optional(NonEmpty),
|
|
226
|
+
}),
|
|
227
|
+
"agent.cancel": v.object({ id: SessionId }),
|
|
228
|
+
"agent.comment": v.object({ id: SessionId, comment: AgentCommentSchema }),
|
|
229
|
+
"agent.permission": v.object({ id: SessionId, requestId: NonEmpty, optionId: NonEmpty }),
|
|
214
230
|
"session.create": v.object({ workspace: WorkspaceSchema, artifact: ArtifactSchema }),
|
|
215
231
|
"session.get": v.object({ id: SessionId }),
|
|
216
232
|
"session.list": v.object({
|