@jxsuite/ai 0.42.0 → 0.43.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/ai",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "AI infrastructure for Jx Suite — streaming LLM client abstraction, tool registry, and reactive chat state",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,8 +33,8 @@
33
33
  "upgrade": "bun update --latest && bun install"
34
34
  },
35
35
  "dependencies": {
36
- "@jxsuite/protocol": "^6.2.0",
37
- "@jxsuite/schema": "^5.0.0",
36
+ "@jxsuite/protocol": "^6.2.2",
37
+ "@jxsuite/schema": "^5.2.0",
38
38
  "@vue/reactivity": "3.5.44"
39
39
  },
40
40
  "devDependencies": {
@@ -12,18 +12,30 @@ import type { TurnPolicy } from "./types.ts";
12
12
  export const CHARS_PER_TOKEN = 4;
13
13
 
14
14
  /**
15
- * The defaults: five rounds of work, twenty-five rounds in all, and the texts the agent loop has
16
- * always used (specs/ai.md §3.2, §3.4).
15
+ * The defaults: thirty rounds of work, sixty rounds in all, three identical rounds, and the texts a
16
+ * turn ends on (specs/ai.md §3.2, §3.4, §3.8).
17
17
  *
18
- * Five bounds AUTONOMOUS work. A round that ends by blocking on a person is the opposite of runaway
19
- * (it cannot advance without them), so a round whose calls were all interactive does not spend that
20
- * budget. Twenty-five is the backstop that keeps a turn provably finite anyway.
18
+ * Thirty bounds AUTONOMOUS work. It was five, which a turn spent finding its way around a project
19
+ * before it had changed or answered anything. A round that ends by blocking on a person is the
20
+ * opposite of runaway (it cannot advance without them), so a round whose calls were all interactive
21
+ * does not spend that budget. Sixty is the backstop that keeps a turn provably finite anyway, and a
22
+ * model going round in circles is stopped long before either, by its repeating itself.
21
23
  */
22
24
  export const DEFAULT_TURN_POLICY: TurnPolicy = {
23
- maxWorkRounds: 5,
24
- maxRounds: 25,
25
+ maxWorkRounds: 30,
26
+ maxRounds: 60,
27
+ maxIdenticalRounds: 3,
28
+ parallelReadOnly: false,
25
29
  estimateTokens: (text) => Math.ceil(text.length / CHARS_PER_TOKEN),
26
- capText: ({ maxWorkRounds, applied, errors }) => {
30
+ closingText: (reason) =>
31
+ `${
32
+ reason === "repeating"
33
+ ? "Your last tool calls keep returning the same results, so they will not help."
34
+ : "This turn has used its whole tool budget."
35
+ } No further tool call will run. This note is from the system, not the author. Reply to the ` +
36
+ `author now in plain text: answer as much as you can from what you already found, say what ` +
37
+ `you changed, and say what is left. They can ask you to continue in their next message.`,
38
+ capText: ({ maxWorkRounds, reason, applied, errors }) => {
27
39
  const unique = [...new Set(errors)];
28
40
  const appliedPart =
29
41
  applied.length > 0
@@ -31,8 +43,12 @@ export const DEFAULT_TURN_POLICY: TurnPolicy = {
31
43
  : "";
32
44
  const errorPart =
33
45
  unique.length > 0 ? `\n\nErrors encountered:\n${unique.map((e) => `- ${e}`).join("\n")}` : "";
46
+ const why =
47
+ reason === "repeating"
48
+ ? "I stopped because my tool calls kept returning the same results."
49
+ : `I ran out of tool-call rounds (${maxWorkRounds}) before finishing.`;
34
50
  return (
35
- `I ran out of tool-call rounds (${maxWorkRounds}) before finishing.${appliedPart}${errorPart}` +
51
+ `${why}${appliedPart}${errorPart}` +
36
52
  `\n\nYou can continue by sending another message, or try a more specific request.`
37
53
  );
38
54
  },
@@ -9,10 +9,11 @@
9
9
  * - **The calls decide whether tools run, not the finish reason** (§2). A round whose stream carried
10
10
  * tool calls executes them whatever finish the provider reported. Cancellation is the exception:
11
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.
12
+ * - **A turn that stops working still answers** (§3.2, §3.8). Running out of work rounds, or making
13
+ * the same calls with the same results, opens one closing round whose calls do not run, so the
14
+ * model says what it found and did. Without a reply, a turn that applied something ends on the
15
+ * harness's message saying what; only one that applied nothing ends as a failure. Applied means
16
+ * it wrote: a call counts when it succeeded with a summary and recorded at least one `ok` write.
16
17
  * - **A turn that drew nothing says so** (§3.2), as an `empty` outcome, rather than ending silent.
17
18
  * - **Stop is checked between calls and after the round** (§2), so a Stop that lands mid-call stops
18
19
  * the calls after it, and one that lands during the last call opens no further round.
@@ -43,6 +44,7 @@ import { joinSystem } from "./model.ts";
43
44
  import { DEFAULT_TURN_POLICY } from "./policy.ts";
44
45
  import type {
45
46
  HarnessEvent,
47
+ CapReason,
46
48
  TurnError,
47
49
  TurnInput,
48
50
  TurnOutcome,
@@ -260,6 +262,10 @@ export function runTurn(input: TurnInput): TurnRun {
260
262
  let workRounds = 0;
261
263
  let lastUsage: Usage | null = null;
262
264
  let totalUsage: Usage | null = null;
265
+ /* How many rounds made exactly these calls and got exactly these results. Nothing the model
266
+ could learn from a further one differs, so the count is what "stuck" means here. */
267
+ const roundsSeen = new Map<string, number>();
268
+ let repeating = false;
263
269
 
264
270
  const finish = (
265
271
  kind: TurnOutcomeKind,
@@ -282,18 +288,85 @@ export function runTurn(input: TurnInput): TurnRun {
282
288
  return outcome;
283
289
  };
284
290
 
291
+ /* How a turn that stopped working ends when the model gave no closing reply. A turn that applied
292
+ something still says what (§3.2); one that applied nothing has nothing to report but that. */
293
+ const capped = (reason: CapReason): TurnOutcome => {
294
+ const text = policy.capText({
295
+ maxWorkRounds: policy.maxWorkRounds,
296
+ reason,
297
+ applied,
298
+ errors,
299
+ });
300
+ if (applied.length === 0) {
301
+ return finish("cap_failed", { error: { message: text } });
302
+ }
303
+ const capId = newId();
304
+ appended.push({
305
+ id: capId,
306
+ role: "assistant",
307
+ blocks: [{ type: "text", text }],
308
+ timestamp: now(),
309
+ meta: { origin: "harness", kind: "round_cap" },
310
+ });
311
+ anchor = capId;
312
+ return finish("cap_partial", { cap: { messageId: capId, text } });
313
+ };
314
+
315
+ /** How many calls from the front of `calls` run together: at least the first. */
316
+ const parallelRunLength = (calls: readonly (readonly [string, { name: string }])[]): number => {
317
+ if (!policy.parallelReadOnly) {
318
+ return 1;
319
+ }
320
+ const end = calls.findIndex(([, call]) => {
321
+ const definition = input.tools.getDefinition(call.name);
322
+ return (
323
+ definition?.annotations?.readOnlyHint !== true ||
324
+ definition.interactive === true ||
325
+ definition.interaction !== undefined
326
+ );
327
+ });
328
+ if (end === -1) {
329
+ return calls.length;
330
+ }
331
+ return Math.max(end, 1);
332
+ };
333
+
285
334
  emit({ type: "turn_start" });
286
335
  const { sealed, dropped, moved } = repair;
287
336
  if (sealed.length + dropped.length + moved.length > 0) {
288
337
  emit({ type: "transcript_repaired", sealed, dropped, moved });
289
338
  }
290
- for (let round = 1; round <= policy.maxRounds && workRounds < policy.maxWorkRounds; round++) {
339
+ /* The turn ends from inside this loop: on a round with no calls, or on the closing round, which
340
+ the bounds below reach whatever the model does. */
341
+ for (let round = 1; ; round++) {
291
342
  rounds = round;
343
+ let closing: CapReason | null = null;
344
+ if (repeating) {
345
+ closing = "repeating";
346
+ } else if (round > policy.maxRounds || workRounds >= policy.maxWorkRounds) {
347
+ closing = "budget";
348
+ }
292
349
  const messageId =
293
350
  round === 1 && input.firstMessageId !== undefined ? input.firstMessageId : newId();
294
- emit({ type: "round_start", round, workRounds, messageId });
351
+ emit(
352
+ closing
353
+ ? { type: "round_start", round, workRounds, messageId, closing }
354
+ : { type: "round_start", round, workRounds, messageId },
355
+ );
295
356
 
357
+ /* A closing round still lists the tools. Taking them away would refuse the request on a
358
+ provider that wants a listing beside any call in the history, and would throw away the
359
+ prompt cache for its last request; a call the model makes anyway is simply not run. */
296
360
  const tools = fromOpenAITools(input.tools.listForLLM());
361
+ const note: ChatMessage | null = closing
362
+ ? {
363
+ id: newId(),
364
+ role: "user",
365
+ blocks: [{ type: "text", text: policy.closingText(closing) }],
366
+ timestamp: now(),
367
+ meta: { origin: "harness", kind: "round_cap" },
368
+ }
369
+ : null;
297
370
  let text = "";
298
371
  let reasoning: string | undefined;
299
372
  /* The message records each call as it starts, the way a transcript shows it; execution keys
@@ -306,9 +379,26 @@ export function runTurn(input: TurnInput): TurnRun {
306
379
  let errorCode: string | undefined;
307
380
 
308
381
  for await (const frame of input.model(
309
- { system: input.system, messages: [...history, ...appended], tools },
382
+ note
383
+ ? {
384
+ system: input.system,
385
+ messages: [...history, ...appended, note],
386
+ tools,
387
+ closing: note,
388
+ }
389
+ : { system: input.system, messages: [...history, ...appended], tools },
310
390
  signal,
311
391
  )) {
392
+ /* A closing round's calls will not run, so they are not drawn either: a call on screen, or
393
+ in the transcript, with no reply is a question this turn has already decided to leave. */
394
+ if (
395
+ closing &&
396
+ (frame.type === "tool_call_start" ||
397
+ frame.type === "tool_call_delta" ||
398
+ frame.type === "tool_call_end")
399
+ ) {
400
+ continue;
401
+ }
312
402
  switch (frame.type) {
313
403
  case "delta": {
314
404
  text += frame.content;
@@ -377,13 +467,20 @@ export function runTurn(input: TurnInput): TurnRun {
377
467
  : { message: errorMessage };
378
468
  input.hooks?.onStreamError?.(error);
379
469
  emit({ type: "round_end", messageId, stopReason, truncated, dropped: true, error });
380
- return finish("error", { error, removedMessageId: messageId });
470
+ // The closing round was a courtesy: losing it is not what the turn failed of.
471
+ return closing && !aborted()
472
+ ? capped(closing)
473
+ : finish("error", { error, removedMessageId: messageId });
381
474
  }
382
475
 
383
476
  /* A turn that has drawn nothing by the end of a round with no calls ends here. A stopped
384
477
  round is not empty: the author ended it. */
385
478
  const stopped = stopReason === "cancelled" || aborted();
386
479
  const drawn = text !== "" || records.length > 0;
480
+ if (closing && !stopped && text.trim() === "") {
481
+ emit({ type: "round_end", messageId, stopReason, truncated, dropped: true });
482
+ return capped(closing);
483
+ }
387
484
  if (calls.size === 0 && !stopped && !drawnBefore && anchor === null && !drawn) {
388
485
  emit({ type: "round_end", messageId, stopReason, truncated, dropped: true });
389
486
  return finish("empty", {
@@ -415,100 +512,111 @@ export function runTurn(input: TurnInput): TurnRun {
415
512
  );
416
513
  anchor = messageId;
417
514
  }
418
- if (calls.size === 0 || stopped) {
419
- return finish(stopped ? "cancelled" : "complete");
515
+ if (stopped) {
516
+ return finish("cancelled");
517
+ }
518
+ if (closing) {
519
+ return finish("cap_partial");
520
+ }
521
+ if (calls.size === 0) {
522
+ return finish("complete");
420
523
  }
421
524
 
525
+ /* The calls run in stream order. With `parallelReadOnly`, a run of neighbouring read-only
526
+ calls runs at once, because none of them can change what another reads; their results are
527
+ still reported in order, so the transcript and the wire see what a serial turn would. A
528
+ question or a write is never in such a run, so it still sees everything before it. */
422
529
  let didWork = false;
423
- for (const [callId, call] of calls) {
530
+ const seen: string[] = [];
531
+ const pending = [...calls];
532
+ while (pending.length > 0) {
424
533
  if (aborted()) {
425
534
  return finish("cancelled");
426
535
  }
427
- emit({
428
- type: "tool_start",
429
- callId,
430
- name: call.name,
431
- interactive: input.tools.getDefinition(call.name)?.interactive === true,
432
- });
433
- const before = ledger.writes.length;
434
- /* The call's own signal: it aborts with the turn, and is unlinked once the call settles, so
435
- a Stop later in the turn reaches nothing that already finished. */
436
- const link = linkCallSignal(signal);
437
- const ctx = createToolContext({
438
- actor,
439
- callId,
440
- ledger,
441
- progress: (progress) => {
442
- emit({ type: "tool_progress", callId, progress });
443
- },
444
- session,
445
- signal: link.signal,
446
- });
447
- let result;
448
- try {
449
- result = await invoke(
450
- { callId, name: call.name, argumentsText: call.argumentsText },
451
- ctx,
452
- link.signal,
453
- );
454
- } finally {
455
- link.release();
456
- }
457
- input.hooks?.afterTool?.({ callId, name: call.name, result });
458
- // A call that suspends the turn on a person does not spend its work budget.
459
- if (input.tools.getDefinition(call.name)?.interactive !== true) {
460
- didWork = true;
461
- }
462
- if (!result.success && result.error) {
463
- errors.push(result.error);
464
- }
465
- const recorded = ledger.writes.slice(before);
466
- if (result.success && result.summary && recorded.some((write) => write.ok)) {
467
- applied.push(result.summary);
468
- }
469
- for (const write of recorded) {
470
- emit({ type: "write", callId, write });
471
- }
472
- const toolMessageId = newId();
473
- appended.push({
474
- id: toolMessageId,
475
- role: "tool",
476
- blocks: [
477
- {
478
- type: "tool_result",
536
+ const batch = pending.splice(0, parallelRunLength(pending));
537
+ let writesSeen = ledger.writes.length;
538
+ const results = await Promise.all(
539
+ batch.map(async ([callId, call]) => {
540
+ emit({
541
+ type: "tool_start",
479
542
  callId,
480
- isError: result.success === false,
481
- content: JSON.stringify(result),
482
- },
483
- ],
484
- timestamp: now(),
485
- });
486
- emit({ type: "tool_result", callId, messageId, toolMessageId, result });
543
+ name: call.name,
544
+ interactive: input.tools.getDefinition(call.name)?.interactive === true,
545
+ });
546
+ /* The call's own signal: it aborts with the turn, and is unlinked once the call
547
+ settles, so a Stop later in the turn reaches nothing that already finished. */
548
+ const link = linkCallSignal(signal);
549
+ const ctx = createToolContext({
550
+ actor,
551
+ callId,
552
+ ledger,
553
+ progress: (progress) => {
554
+ emit({ type: "tool_progress", callId, progress });
555
+ },
556
+ session,
557
+ signal: link.signal,
558
+ });
559
+ try {
560
+ return await invoke(
561
+ { callId, name: call.name, argumentsText: call.argumentsText },
562
+ ctx,
563
+ link.signal,
564
+ );
565
+ } finally {
566
+ link.release();
567
+ }
568
+ }),
569
+ );
570
+ for (const [index, [callId, call]] of batch.entries()) {
571
+ const result = results[index]!;
572
+ input.hooks?.afterTool?.({ callId, name: call.name, result });
573
+ // A call that suspends the turn on a person does not spend its work budget.
574
+ if (input.tools.getDefinition(call.name)?.interactive !== true) {
575
+ didWork = true;
576
+ }
577
+ if (!result.success && result.error) {
578
+ errors.push(result.error);
579
+ }
580
+ const recorded = ledger.writes.slice(writesSeen);
581
+ writesSeen = ledger.writes.length;
582
+ if (result.success && result.summary && recorded.some((write) => write.ok)) {
583
+ applied.push(result.summary);
584
+ }
585
+ for (const write of recorded) {
586
+ emit({ type: "write", callId, write });
587
+ }
588
+ const content = JSON.stringify(result);
589
+ seen.push(call.name, call.argumentsText, content);
590
+ const toolMessageId = newId();
591
+ appended.push({
592
+ id: toolMessageId,
593
+ role: "tool",
594
+ blocks: [
595
+ {
596
+ type: "tool_result",
597
+ callId,
598
+ isError: result.success === false,
599
+ content,
600
+ },
601
+ ],
602
+ timestamp: now(),
603
+ });
604
+ emit({ type: "tool_result", callId, messageId, toolMessageId, result });
605
+ }
487
606
  }
488
607
 
489
608
  if (didWork) {
490
609
  workRounds += 1;
610
+ const fingerprint = JSON.stringify(seen);
611
+ const count = (roundsSeen.get(fingerprint) ?? 0) + 1;
612
+ roundsSeen.set(fingerprint, count);
613
+ repeating = count >= policy.maxIdenticalRounds;
491
614
  }
492
615
  // A Stop that landed during the round's last call opens no further round.
493
616
  if (aborted()) {
494
617
  return finish("cancelled");
495
618
  }
496
619
  }
497
-
498
- const capText = policy.capText({ maxWorkRounds: policy.maxWorkRounds, applied, errors });
499
- if (applied.length > 0) {
500
- const capId = newId();
501
- appended.push({
502
- id: capId,
503
- role: "assistant",
504
- blocks: [{ type: "text", text: capText }],
505
- timestamp: now(),
506
- meta: { origin: "harness", kind: "round_cap" },
507
- });
508
- anchor = capId;
509
- return finish("cap_partial", { cap: { messageId: capId, text: capText } });
510
- }
511
- return finish("cap_failed", { error: { message: capText } });
512
620
  }
513
621
 
514
622
  const outcome = loop().finally(settle);
@@ -33,6 +33,11 @@ export interface ModelRequest {
33
33
  /** The conversation so far, this turn's own messages included. */
34
34
  readonly messages: readonly ChatMessage[];
35
35
  readonly tools: readonly ToolSpec[];
36
+ /**
37
+ * The note a closing round ends on (specs/ai.md §3.8), already the last of `messages`. It is on
38
+ * no transcript, so a host that projects its request from its own transcript sends it from here.
39
+ */
40
+ readonly closing?: ChatMessage;
36
41
  }
37
42
 
38
43
  /** Streams one round's answer. Called exactly once per round. */
@@ -51,9 +56,10 @@ export interface TurnError {
51
56
  * How a turn ended.
52
57
  *
53
58
  * - `complete`: the model stopped calling tools after drawing something.
54
- * - `cap_partial`: the work budget ran out after something was applied; the turn ends on a message
55
- * saying what.
56
- * - `cap_failed`: the work budget ran out with nothing applied.
59
+ * - `cap_partial`: the turn stopped working (its work budget ran out, or it was repeating itself) and
60
+ * ended on a message: the model's closing reply, or the harness's when something was applied and
61
+ * the model gave none.
62
+ * - `cap_failed`: the turn stopped working, nothing was applied, and the model gave no closing reply.
57
63
  * - `error`: the provider sent an error frame.
58
64
  * - `cancelled`: the turn was stopped.
59
65
  * - `empty`: the model answered with neither text nor a tool call, and nothing earlier in the turn
@@ -67,17 +73,27 @@ export type TurnOutcomeKind =
67
73
  | "cancelled"
68
74
  | "empty";
69
75
 
76
+ /** Why a turn stopped working: its work budget ran out, or it was repeating itself. */
77
+ export type CapReason = "budget" | "repeating";
78
+
70
79
  /** The numbers and words a turn is bounded and reported by. */
71
80
  export interface TurnPolicy {
72
81
  /** Rounds that did work: a round whose calls were all interactive does not count. */
73
82
  readonly maxWorkRounds: number;
74
83
  /** Rounds of every kind, so a turn ends whatever the model does. */
75
84
  readonly maxRounds: number;
85
+ /** How many rounds of the same calls with the same results it takes to call a turn stuck. */
86
+ readonly maxIdenticalRounds: number;
87
+ /** What a turn's closing round tells the model. */
88
+ readonly closingText: (reason: CapReason) => string;
89
+ /** Whether neighbouring read-only calls in a round run at once. Their results stay in order. */
90
+ readonly parallelReadOnly: boolean;
76
91
  /** A token estimate for a text, reported beside each usage count for the system prompt. */
77
92
  readonly estimateTokens: (text: string) => number;
78
- /** What a turn that ran out of work rounds says. */
93
+ /** What a turn says when it stopped working and the model gave no closing reply. */
79
94
  readonly capText: (cap: {
80
95
  readonly maxWorkRounds: number;
96
+ readonly reason: CapReason;
81
97
  readonly applied: readonly string[];
82
98
  readonly errors: readonly string[];
83
99
  }) => string;
@@ -209,6 +225,8 @@ export type HarnessEvent = EventBase &
209
225
  readonly round: number;
210
226
  readonly workRounds: number;
211
227
  readonly messageId: string;
228
+ /** Set on a closing round (specs/ai.md §3.8): the last, whose calls will not run. */
229
+ readonly closing?: CapReason;
212
230
  }
213
231
  | { readonly type: "text"; readonly messageId: string; readonly text: string }
214
232
  | { readonly type: "reasoning"; readonly messageId: string; readonly text: string }
@@ -287,7 +305,7 @@ export interface TurnOutcome {
287
305
  readonly usage: { readonly last: Usage | null; readonly total: Usage | null };
288
306
  /** Every message the turn added, in its final form. */
289
307
  readonly appended: readonly ChatMessage[];
290
- /** For `cap_partial`: the message the turn ended on. */
308
+ /** For a `cap_partial` the harness ended, the model having given no closing reply: its message. */
291
309
  readonly cap?: { readonly messageId: string; readonly text: string };
292
310
  /** For `error` and `empty`: the round message that was dropped. */
293
311
  readonly removedMessageId?: string;