@jxsuite/ai 0.42.1 → 0.43.1
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/harness/policy.ts +25 -9
- package/src/harness/run.ts +193 -85
- package/src/harness/types.ts +23 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.1",
|
|
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.
|
|
37
|
-
"@jxsuite/schema": "^5.
|
|
36
|
+
"@jxsuite/protocol": "^6.2.3",
|
|
37
|
+
"@jxsuite/schema": "^5.3.0",
|
|
38
38
|
"@vue/reactivity": "3.5.44"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
package/src/harness/policy.ts
CHANGED
|
@@ -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:
|
|
16
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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:
|
|
24
|
-
maxRounds:
|
|
25
|
+
maxWorkRounds: 30,
|
|
26
|
+
maxRounds: 60,
|
|
27
|
+
maxIdenticalRounds: 3,
|
|
28
|
+
parallelReadOnly: false,
|
|
25
29
|
estimateTokens: (text) => Math.ceil(text.length / CHARS_PER_TOKEN),
|
|
26
|
-
|
|
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
|
-
|
|
51
|
+
`${why}${appliedPart}${errorPart}` +
|
|
36
52
|
`\n\nYou can continue by sending another message, or try a more specific request.`
|
|
37
53
|
);
|
|
38
54
|
},
|
package/src/harness/run.ts
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
419
|
-
return finish(
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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);
|
package/src/harness/types.ts
CHANGED
|
@@ -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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
|
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
|
|
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;
|