@jxsuite/ai 0.39.1 → 0.39.3

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.
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Run.ts — the turn engine: one user turn, round by round, until the model stops calling tools.
3
+ *
4
+ * This is the agent loop Studio ran inline (specs/ai.md §3.2 to §3.4, §3.8), moved behind an
5
+ * interface any host can run: a Studio window, a Worker, or a headless test. It owns the rules a
6
+ * turn is held to and reports everything it does as events; a host that keeps its own transcript
7
+ * applies them as they arrive.
8
+ *
9
+ * - **The calls decide whether tools run, not the finish reason** (§2). A round whose stream carried
10
+ * tool calls executes them whatever finish the provider reported. Cancellation is the exception:
11
+ * a round that ends `cancelled`, or whose signal is aborted, runs nothing it streamed.
12
+ * - **A partial success is not a failure** (§3.2). Running out of work rounds after applying
13
+ * something ends on an ordinary message saying what was applied; only a turn that applied nothing
14
+ * ends as a failure. Applied means it wrote: a call counts when it succeeded with a summary and
15
+ * recorded at least one `ok` write.
16
+ * - **A turn that drew nothing says so** (§3.2), as an `empty` outcome, rather than ending silent.
17
+ * - **Stop is checked between calls and after the round** (§2), so a Stop that lands mid-call stops
18
+ * the calls after it, and one that lands during the last call opens no further round.
19
+ * - **The history is repaired before it is sent** (§3.4). Every tool call is put back beside its
20
+ * reply, and a call with none is sealed, so a history a restore, a trim or a Stop broke does not
21
+ * get the turn's first request refused.
22
+ *
23
+ * @module @jxsuite/ai/harness
24
+ * @license MIT
25
+ */
26
+
27
+ import { nextMessageId } from "../message-id.ts";
28
+ import { isEmptyAssistant } from "../messages/convert.ts";
29
+ import { fromOpenAITools } from "../messages/openai.ts";
30
+ import { repairToolPairs } from "../messages/repair.ts";
31
+ import type { Block, ChatMessage } from "../messages/types.ts";
32
+ import {
33
+ createLedger,
34
+ createSessionFacts,
35
+ createToolContext,
36
+ invokeTool,
37
+ linkCallSignal,
38
+ } from "../tools.ts";
39
+ import type { Actor } from "../tools.ts";
40
+ import { LaneBusyError } from "./lock.ts";
41
+ import { joinSystem } from "./model.ts";
42
+ import { DEFAULT_TURN_POLICY } from "./policy.ts";
43
+ import type {
44
+ HarnessEvent,
45
+ TurnError,
46
+ TurnInput,
47
+ TurnOutcome,
48
+ TurnOutcomeKind,
49
+ TurnRun,
50
+ Usage,
51
+ } from "./types.ts";
52
+
53
+ /** An event as the engine writes it, before the envelope is added. */
54
+ type EventBody = HarnessEvent extends infer E
55
+ ? E extends HarnessEvent
56
+ ? Omit<E, "v" | "seq" | "turnId">
57
+ : never
58
+ : never;
59
+
60
+ /** Two usage counts summed field by field; a field either one reports is reported. */
61
+ function addUsage(total: Usage | null, next: Usage): Usage {
62
+ if (total === null) {
63
+ return { ...next };
64
+ }
65
+ const sum: Record<string, number> = { ...total };
66
+ for (const [key, value] of Object.entries(next)) {
67
+ if (typeof value === "number") {
68
+ sum[key] = (sum[key] ?? 0) + value;
69
+ }
70
+ }
71
+ return sum as unknown as Usage;
72
+ }
73
+
74
+ /**
75
+ * The events of a turn, in order, to every reader: buffered for the turn's life, so a reader that
76
+ * starts late still sees the turn from its start, and two readers each see all of it.
77
+ */
78
+ function createEventQueue() {
79
+ const events: HarnessEvent[] = [];
80
+ let closed = false;
81
+ let failure: { error: unknown } | null = null;
82
+ const waiting = new Set<() => void>();
83
+ const notify = () => {
84
+ const resumes = [...waiting];
85
+ waiting.clear();
86
+ for (const resume of resumes) {
87
+ resume();
88
+ }
89
+ };
90
+ const nextPush = () =>
91
+ new Promise<void>((resume) => {
92
+ waiting.add(resume);
93
+ });
94
+ return {
95
+ push(event: HarnessEvent) {
96
+ events.push(event);
97
+ notify();
98
+ },
99
+ close(error?: { error: unknown }) {
100
+ closed = true;
101
+ failure = error ?? null;
102
+ notify();
103
+ },
104
+ async *read(): AsyncGenerator<HarnessEvent> {
105
+ let index = 0;
106
+ while (true) {
107
+ if (index < events.length) {
108
+ yield events[index]!;
109
+ index += 1;
110
+ } else if (closed) {
111
+ if (failure) {
112
+ throw failure.error;
113
+ }
114
+ return;
115
+ } else {
116
+ await nextPush();
117
+ }
118
+ }
119
+ },
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Start one turn. It begins at once and runs to its end whether or not anyone reads its events;
125
+ * `onEvent` hears each one synchronously, in order, before the engine takes its next step.
126
+ *
127
+ * @param {TurnInput} input
128
+ * @returns {TurnRun}
129
+ * @throws {LaneBusyError} Synchronously, when `input.lock` is held by another turn
130
+ */
131
+ export function runTurn(input: TurnInput): TurnRun {
132
+ const newId = input.newId ?? nextMessageId;
133
+ const now = input.now ?? Date.now;
134
+ const turnId = input.turnId ?? newId();
135
+ const release = input.lock ? input.lock.acquire(turnId) : () => {};
136
+ if (release === null) {
137
+ throw new LaneBusyError(input.lock?.active ?? "");
138
+ }
139
+
140
+ /* The turn's own signal, armed before this function returns: `cancel` aborts it, and so does the
141
+ host's signal, with the host's reason. */
142
+ const controller = new AbortController();
143
+ let unlink = () => {};
144
+ const outer = input.signal;
145
+ if (outer?.aborted) {
146
+ controller.abort(outer.reason);
147
+ } else if (outer) {
148
+ const onAbort = () => {
149
+ controller.abort(outer.reason);
150
+ };
151
+ outer.addEventListener("abort", onAbort, { once: true });
152
+ unlink = () => {
153
+ outer.removeEventListener("abort", onAbort);
154
+ };
155
+ }
156
+ const { signal } = controller;
157
+ const settle = () => {
158
+ unlink();
159
+ release();
160
+ };
161
+
162
+ const policy = { ...DEFAULT_TURN_POLICY, ...input.policy };
163
+ const session = input.session ?? createSessionFacts();
164
+ const ledger = input.ledger ?? createLedger();
165
+ const sessionId = input.sessionId === undefined ? session.sessionId : input.sessionId;
166
+ const actor: Actor = {
167
+ id: `assistant:${sessionId ?? "local"}:${turnId}`,
168
+ kind: "assistant",
169
+ ...(input.modelInfo ? { model: input.modelInfo.model } : {}),
170
+ sessionId,
171
+ turnId,
172
+ };
173
+
174
+ const queue = createEventQueue();
175
+ let seq = 0;
176
+ let ended = false;
177
+ /* Nothing follows `turn_end`: a tool that reports progress after its turn has ended (a call that
178
+ outlives its signal) is not heard. */
179
+ const emit = (body: EventBody) => {
180
+ if (ended) {
181
+ return;
182
+ }
183
+ ended = body.type === "turn_end";
184
+ const event = { v: 1, seq, turnId, ...body } as HarnessEvent;
185
+ seq += 1;
186
+ input.onEvent?.(event);
187
+ queue.push(event);
188
+ };
189
+
190
+ /* Every call beside its reply before anything reads the history (specs/ai.md §3.4): a provider
191
+ refuses a request that repeats a call without its reply, and a history can arrive broken (a
192
+ restore, a trim, a turn stopped between calls). */
193
+ const repair = repairToolPairs(input.history);
194
+ const history = repair.messages;
195
+ /* Whether the turn has drawn anything before its first round: an assistant message after the
196
+ message it answers (or anywhere, when there is none) with text or a call. */
197
+ const answered = history.findLastIndex((message) => message.role === "user");
198
+ const drawnBefore = history
199
+ .slice(answered + 1)
200
+ .some((message) => message.role === "assistant" && !isEmptyAssistant(message));
201
+
202
+ async function loop(): Promise<TurnOutcome> {
203
+ const appended: ChatMessage[] = [];
204
+ const applied: string[] = [];
205
+ const errors: string[] = [];
206
+ let anchor: string | null = null;
207
+ let rounds = 0;
208
+ let workRounds = 0;
209
+ let lastUsage: Usage | null = null;
210
+ let totalUsage: Usage | null = null;
211
+
212
+ const finish = (
213
+ kind: TurnOutcomeKind,
214
+ extra: Pick<TurnOutcome, "cap" | "error" | "removedMessageId"> = {},
215
+ ): TurnOutcome => {
216
+ const outcome: TurnOutcome = {
217
+ kind,
218
+ turnId,
219
+ anchorMessageId: anchor,
220
+ rounds,
221
+ workRounds,
222
+ writes: [...ledger.writes],
223
+ usage: { last: lastUsage, total: totalUsage },
224
+ appended,
225
+ ...extra,
226
+ };
227
+ // Released before the last event, so a host that starts its next turn on it is not refused.
228
+ settle();
229
+ emit({ type: "turn_end", outcome });
230
+ return outcome;
231
+ };
232
+
233
+ emit({ type: "turn_start" });
234
+ const { sealed, dropped, moved } = repair;
235
+ if (sealed.length + dropped.length + moved.length > 0) {
236
+ emit({ type: "transcript_repaired", sealed, dropped, moved });
237
+ }
238
+ for (let round = 1; round <= policy.maxRounds && workRounds < policy.maxWorkRounds; round++) {
239
+ rounds = round;
240
+ const messageId =
241
+ round === 1 && input.firstMessageId !== undefined ? input.firstMessageId : newId();
242
+ emit({ type: "round_start", round, workRounds, messageId });
243
+
244
+ const tools = fromOpenAITools(input.tools.listForLLM());
245
+ let text = "";
246
+ let reasoning: string | undefined;
247
+ /* The message records each call as it starts, the way a transcript shows it; execution keys
248
+ them by id, so a call id streamed twice runs once, with the arguments of its last start. */
249
+ const records: { id: string; name: string; argumentsText: string }[] = [];
250
+ const calls = new Map<string, { name: string; argumentsText: string }>();
251
+ let stopReason = "stop";
252
+ let sawDone = false;
253
+ let errorMessage = "";
254
+ let errorCode: string | undefined;
255
+
256
+ for await (const frame of input.model(
257
+ { system: input.system, messages: [...history, ...appended], tools },
258
+ signal,
259
+ )) {
260
+ switch (frame.type) {
261
+ case "delta": {
262
+ text += frame.content;
263
+ emit({ type: "text", messageId, text: frame.content });
264
+ break;
265
+ }
266
+ case "reasoning": {
267
+ reasoning = (reasoning ?? "") + frame.content;
268
+ emit({ type: "reasoning", messageId, text: frame.content });
269
+ break;
270
+ }
271
+ case "tool_call_start": {
272
+ records.push({ id: frame.id, name: frame.name, argumentsText: "" });
273
+ calls.set(frame.id, { name: frame.name, argumentsText: "" });
274
+ emit({ type: "tool_call_start", messageId, callId: frame.id, name: frame.name });
275
+ break;
276
+ }
277
+ case "tool_call_delta": {
278
+ const record = records.find((r) => r.id === frame.id);
279
+ if (record) {
280
+ record.argumentsText += frame.args;
281
+ }
282
+ const call = calls.get(frame.id);
283
+ if (call) {
284
+ call.argumentsText += frame.args;
285
+ }
286
+ emit({ type: "tool_call_delta", messageId, callId: frame.id, args: frame.args });
287
+ break;
288
+ }
289
+ case "tool_call_end": {
290
+ emit({ type: "tool_call_end", messageId, callId: frame.id });
291
+ break;
292
+ }
293
+ case "usage": {
294
+ const { type: _type, ...usage } = frame;
295
+ lastUsage = usage;
296
+ totalUsage = addUsage(totalUsage, usage);
297
+ emit({
298
+ type: "usage",
299
+ messageId,
300
+ frame,
301
+ systemTokens: policy.estimateTokens(joinSystem(input.system)),
302
+ });
303
+ break;
304
+ }
305
+ case "done": {
306
+ ({ stopReason } = frame);
307
+ sawDone = true;
308
+ break;
309
+ }
310
+ case "error": {
311
+ errorMessage = frame.message;
312
+ errorCode = frame.code;
313
+ break;
314
+ }
315
+ default: {
316
+ break;
317
+ }
318
+ }
319
+ }
320
+
321
+ const truncated = !sawDone;
322
+ if (errorMessage) {
323
+ const error: TurnError = {
324
+ message: errorMessage,
325
+ ...(errorCode ? { code: errorCode } : {}),
326
+ };
327
+ input.hooks?.onStreamError?.(error);
328
+ emit({ type: "round_end", messageId, stopReason, truncated, dropped: true, error });
329
+ return finish("error", { error, removedMessageId: messageId });
330
+ }
331
+
332
+ /* A turn that has drawn nothing by the end of a round with no calls ends here. A stopped
333
+ round is not empty: the author ended it. */
334
+ const stopped = stopReason === "cancelled" || signal.aborted;
335
+ const drawn = text !== "" || records.length > 0;
336
+ if (calls.size === 0 && !stopped && !drawnBefore && anchor === null && !drawn) {
337
+ emit({ type: "round_end", messageId, stopReason, truncated, dropped: true });
338
+ return finish("empty", {
339
+ error: { message: policy.emptyText },
340
+ removedMessageId: messageId,
341
+ });
342
+ }
343
+
344
+ emit({ type: "round_end", messageId, stopReason, truncated, dropped: false });
345
+ if (drawn) {
346
+ const blocks: Block[] = [];
347
+ if (reasoning !== undefined) {
348
+ blocks.push({ type: "reasoning", text: reasoning });
349
+ }
350
+ if (text) {
351
+ blocks.push({ type: "text", text });
352
+ }
353
+ for (const record of records) {
354
+ blocks.push({ type: "tool_call", ...record });
355
+ }
356
+ appended.push({
357
+ id: messageId,
358
+ role: "assistant",
359
+ blocks,
360
+ timestamp: now(),
361
+ ...(truncated ? { meta: { incomplete: "truncated" as const } } : {}),
362
+ });
363
+ anchor = messageId;
364
+ }
365
+ if (calls.size === 0 || stopped) {
366
+ return finish(stopped ? "cancelled" : "complete");
367
+ }
368
+
369
+ let didWork = false;
370
+ for (const [callId, call] of calls) {
371
+ if (signal.aborted) {
372
+ return finish("cancelled");
373
+ }
374
+ emit({
375
+ type: "tool_start",
376
+ callId,
377
+ name: call.name,
378
+ interactive: input.tools.getDefinition(call.name)?.interactive === true,
379
+ });
380
+ const before = ledger.writes.length;
381
+ /* The call's own signal: it aborts with the turn, and is unlinked once the call settles, so
382
+ a Stop later in the turn reaches nothing that already finished. */
383
+ const link = linkCallSignal(signal);
384
+ const ctx = createToolContext({
385
+ actor,
386
+ callId,
387
+ ledger,
388
+ progress: (progress) => {
389
+ emit({ type: "tool_progress", callId, progress });
390
+ },
391
+ session,
392
+ signal: link.signal,
393
+ });
394
+ let result;
395
+ try {
396
+ ({ result } = await invokeTool(
397
+ input.tools,
398
+ { callId, name: call.name, argumentsText: call.argumentsText },
399
+ ctx,
400
+ ));
401
+ } finally {
402
+ link.release();
403
+ }
404
+ input.hooks?.afterTool?.({ callId, name: call.name, result });
405
+ // A call that suspends the turn on a person does not spend its work budget.
406
+ if (input.tools.getDefinition(call.name)?.interactive !== true) {
407
+ didWork = true;
408
+ }
409
+ if (!result.success && result.error) {
410
+ errors.push(result.error);
411
+ }
412
+ const recorded = ledger.writes.slice(before);
413
+ if (result.success && result.summary && recorded.some((write) => write.ok)) {
414
+ applied.push(result.summary);
415
+ }
416
+ for (const write of recorded) {
417
+ emit({ type: "write", callId, write });
418
+ }
419
+ const toolMessageId = newId();
420
+ appended.push({
421
+ id: toolMessageId,
422
+ role: "tool",
423
+ blocks: [
424
+ {
425
+ type: "tool_result",
426
+ callId,
427
+ isError: result.success === false,
428
+ content: JSON.stringify(result),
429
+ },
430
+ ],
431
+ timestamp: now(),
432
+ });
433
+ emit({ type: "tool_result", callId, messageId, toolMessageId, result });
434
+ }
435
+
436
+ if (didWork) {
437
+ workRounds += 1;
438
+ }
439
+ // A Stop that landed during the round's last call opens no further round.
440
+ if (signal.aborted) {
441
+ return finish("cancelled");
442
+ }
443
+ }
444
+
445
+ const capText = policy.capText({ maxWorkRounds: policy.maxWorkRounds, applied, errors });
446
+ if (applied.length > 0) {
447
+ const capId = newId();
448
+ appended.push({
449
+ id: capId,
450
+ role: "assistant",
451
+ blocks: [{ type: "text", text: capText }],
452
+ timestamp: now(),
453
+ meta: { origin: "harness", kind: "round_cap" },
454
+ });
455
+ anchor = capId;
456
+ return finish("cap_partial", { cap: { messageId: capId, text: capText } });
457
+ }
458
+ return finish("cap_failed", { error: { message: capText } });
459
+ }
460
+
461
+ const outcome = loop().finally(settle);
462
+ outcome.then(
463
+ () => {
464
+ queue.close();
465
+ },
466
+ (error: unknown) => {
467
+ queue.close({ error });
468
+ },
469
+ );
470
+
471
+ return {
472
+ turnId,
473
+ signal,
474
+ cancel(reason = "user") {
475
+ controller.abort(reason);
476
+ },
477
+ outcome,
478
+ [Symbol.asyncIterator]: () => queue.read(),
479
+ };
480
+ }