@cubicecho/agent-core 2.23.0 → 2.24.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/README.md +4 -2
- package/dist/agent-loop.d.ts +3 -2
- package/dist/agent-loop.js +385 -303
- package/dist/calibration.js +2 -1
- package/dist/capabilities.d.ts +36 -0
- package/dist/capabilities.js +106 -56
- package/dist/client.js +11 -10
- package/dist/compaction.js +5 -9
- package/dist/continuation.d.ts +4 -4
- package/dist/continuation.js +1 -4
- package/dist/events.d.ts +13 -0
- package/dist/events.js +28 -21
- package/dist/guards.d.ts +22 -0
- package/dist/guards.js +22 -0
- package/dist/hook-events.d.ts +22 -0
- package/dist/hook-events.js +22 -0
- package/dist/hooks.d.ts +13 -14
- package/dist/hooks.js +35 -36
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/retry.d.ts +13 -7
- package/dist/retry.js +14 -7
- package/dist/run-turn.d.ts +8 -1
- package/dist/schema-compat.d.ts +23 -0
- package/dist/schema-compat.js +76 -59
- package/dist/side-task.d.ts +5 -0
- package/dist/side-task.js +18 -47
- package/dist/snapshot.d.ts +2 -2
- package/dist/snapshot.js +27 -48
- package/dist/spec.d.ts +11 -12
- package/dist/spec.js +36 -66
- package/dist/stream.d.ts +7 -0
- package/dist/stream.js +8 -1
- package/dist/thinking.js +6 -4
- package/dist/tokens.d.ts +9 -2
- package/dist/tokens.js +10 -3
- package/dist/tool-calls.d.ts +9 -0
- package/dist/tool-calls.js +11 -6
- package/dist/tool-loading.d.ts +10 -0
- package/dist/tool-loading.js +15 -7
- package/llms.txt +12 -5
- package/package.json +1 -1
package/dist/calibration.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { requestChars, toolsChars } from "./retry.js";
|
|
2
2
|
import { scoped } from "./scope.js";
|
|
3
|
+
import { CHARS_PER_TOKEN } from "./tokens.js";
|
|
3
4
|
/**
|
|
4
5
|
* How many characters a token is worth on one model, learned from what its endpoint reports.
|
|
5
6
|
*
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -118,6 +118,20 @@ export interface ModelCapabilities {
|
|
|
118
118
|
*/
|
|
119
119
|
assistantPrefill: boolean;
|
|
120
120
|
}
|
|
121
|
+
/** The name of one of a model's on/off flags: every `boolean` on `ModelCapabilities`. */
|
|
122
|
+
export type ModelFlag = {
|
|
123
|
+
[Key in keyof ModelCapabilities]-?: ModelCapabilities[Key] extends boolean ? Key : never;
|
|
124
|
+
}[keyof ModelCapabilities];
|
|
125
|
+
/**
|
|
126
|
+
* A model's on/off flags as it starts out, every one optimistic.
|
|
127
|
+
*
|
|
128
|
+
* A record over every boolean on `ModelCapabilities`, so a flag added there does not compile until
|
|
129
|
+
* it is listed here — and this is the list a snapshot is exported, imported and judged empty by,
|
|
130
|
+
* where each of those used to name the flags again and one could be missed.
|
|
131
|
+
*/
|
|
132
|
+
export declare const OPTIMISTIC_MODEL: Readonly<Record<ModelFlag, true>>;
|
|
133
|
+
/** The names of those flags, for a caller that reads or latches each in turn. */
|
|
134
|
+
export declare const MODEL_FLAGS: ModelFlag[];
|
|
121
135
|
/**
|
|
122
136
|
* What this endpoint is known not to support. The same object every time, so what `negotiate`
|
|
123
137
|
* latches off stays off.
|
|
@@ -210,6 +224,28 @@ export declare const EFFORT_LADDER: readonly ["none", "minimal", "low", "medium"
|
|
|
210
224
|
* @param asked What the config asks for. `"off"` and absent mean no effort.
|
|
211
225
|
*/
|
|
212
226
|
export declare function effortFor(refused: ModelCapabilities | undefined, asked: string | undefined): string;
|
|
227
|
+
/**
|
|
228
|
+
* The reply ceiling and the temperature as this model takes them, to spread into a request body.
|
|
229
|
+
*
|
|
230
|
+
* The reasoning models want the ceiling spelled the other way, and one that will only run at the
|
|
231
|
+
* temperature it was built with is sent none. Both are tested `=== false`, so a caller that named
|
|
232
|
+
* no model sends what one that has refused nothing is sent; see `ModelCapabilities.legacyTokenLimit`.
|
|
233
|
+
*
|
|
234
|
+
* @param refused What the model has refused, as `negotiate` hands it over. Absent is a model that
|
|
235
|
+
* has refused nothing.
|
|
236
|
+
* @param maxTokens The ceiling. Zero or less sends none and leaves it to the server: a zero sent
|
|
237
|
+
* as `max_tokens: 0` asks for an empty reply.
|
|
238
|
+
* @param temperature What to sample at, where the model takes one that was picked for it.
|
|
239
|
+
*/
|
|
240
|
+
export declare const ceilingAndTemperature: (refused: ModelCapabilities | undefined, maxTokens: number, temperature: number) => {
|
|
241
|
+
max_completion_tokens: number;
|
|
242
|
+
temperature?: number | undefined;
|
|
243
|
+
} | {
|
|
244
|
+
max_tokens: number;
|
|
245
|
+
temperature?: number | undefined;
|
|
246
|
+
} | {
|
|
247
|
+
temperature?: number | undefined;
|
|
248
|
+
};
|
|
213
249
|
/** What `negotiate` takes besides the request. All optional. */
|
|
214
250
|
export interface NegotiateOptions {
|
|
215
251
|
/**
|
package/dist/capabilities.js
CHANGED
|
@@ -2,6 +2,22 @@ import { endpointId } from "./client.js";
|
|
|
2
2
|
import { errorMessage } from "./errors.js";
|
|
3
3
|
import { isGrammarError } from "./schema-compat.js";
|
|
4
4
|
import { scoped } from "./scope.js";
|
|
5
|
+
/**
|
|
6
|
+
* A model's on/off flags as it starts out, every one optimistic.
|
|
7
|
+
*
|
|
8
|
+
* A record over every boolean on `ModelCapabilities`, so a flag added there does not compile until
|
|
9
|
+
* it is listed here — and this is the list a snapshot is exported, imported and judged empty by,
|
|
10
|
+
* where each of those used to name the flags again and one could be missed.
|
|
11
|
+
*/
|
|
12
|
+
export const OPTIMISTIC_MODEL = {
|
|
13
|
+
reasoningEffort: true,
|
|
14
|
+
legacyTokenLimit: true,
|
|
15
|
+
chosenTemperature: true,
|
|
16
|
+
structuredOutput: true,
|
|
17
|
+
assistantPrefill: true,
|
|
18
|
+
};
|
|
19
|
+
/** The names of those flags, for a caller that reads or latches each in turn. */
|
|
20
|
+
export const MODEL_FLAGS = Object.keys(OPTIMISTIC_MODEL);
|
|
5
21
|
/**
|
|
6
22
|
* What each endpoint cannot do, remembered for the life of the process.
|
|
7
23
|
*
|
|
@@ -51,15 +67,7 @@ export function capabilitiesFor(baseUrl, apiKey) {
|
|
|
51
67
|
export function modelCapabilitiesFor(supports, model) {
|
|
52
68
|
let known = supports.models.get(model);
|
|
53
69
|
if (!known) {
|
|
54
|
-
known = {
|
|
55
|
-
reasoningEffort: true,
|
|
56
|
-
legacyTokenLimit: true,
|
|
57
|
-
chosenTemperature: true,
|
|
58
|
-
refusedFields: new Set(),
|
|
59
|
-
structuredOutput: true,
|
|
60
|
-
refusedEfforts: new Set(),
|
|
61
|
-
assistantPrefill: true,
|
|
62
|
-
};
|
|
70
|
+
known = { ...OPTIMISTIC_MODEL, refusedFields: new Set(), refusedEfforts: new Set() };
|
|
63
71
|
supports.models.set(model, known);
|
|
64
72
|
}
|
|
65
73
|
return known;
|
|
@@ -261,6 +269,27 @@ export function effortFor(refused, asked) {
|
|
|
261
269
|
return asked;
|
|
262
270
|
return nextEffort(refused, asked) ?? asked;
|
|
263
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* The reply ceiling and the temperature as this model takes them, to spread into a request body.
|
|
274
|
+
*
|
|
275
|
+
* The reasoning models want the ceiling spelled the other way, and one that will only run at the
|
|
276
|
+
* temperature it was built with is sent none. Both are tested `=== false`, so a caller that named
|
|
277
|
+
* no model sends what one that has refused nothing is sent; see `ModelCapabilities.legacyTokenLimit`.
|
|
278
|
+
*
|
|
279
|
+
* @param refused What the model has refused, as `negotiate` hands it over. Absent is a model that
|
|
280
|
+
* has refused nothing.
|
|
281
|
+
* @param maxTokens The ceiling. Zero or less sends none and leaves it to the server: a zero sent
|
|
282
|
+
* as `max_tokens: 0` asks for an empty reply.
|
|
283
|
+
* @param temperature What to sample at, where the model takes one that was picked for it.
|
|
284
|
+
*/
|
|
285
|
+
export const ceilingAndTemperature = (refused, maxTokens, temperature) => ({
|
|
286
|
+
...(maxTokens > 0
|
|
287
|
+
? refused?.legacyTokenLimit === false
|
|
288
|
+
? { max_completion_tokens: maxTokens }
|
|
289
|
+
: { max_tokens: maxTokens }
|
|
290
|
+
: {}),
|
|
291
|
+
...(refused?.chosenTemperature === false ? {} : { temperature }),
|
|
292
|
+
});
|
|
264
293
|
/**
|
|
265
294
|
* How to answer a refused effort *value*, or `undefined` when there is nothing new to learn or
|
|
266
295
|
* nowhere left to go — in which case the refusal is the caller's, as it was before this existed.
|
|
@@ -347,9 +376,58 @@ function unknownFields(detail) {
|
|
|
347
376
|
.split(/[.[]/)[0])
|
|
348
377
|
.filter(Boolean);
|
|
349
378
|
}
|
|
350
|
-
/**
|
|
351
|
-
const
|
|
352
|
-
|
|
379
|
+
/** The answer to a refusal that is the endpoint's: a flag still set, and a wording that clears it. */
|
|
380
|
+
const endpointAnswer = (flag, matches, notice) => ({ detail, supports }) => {
|
|
381
|
+
if (!supports[flag] || !matches(detail))
|
|
382
|
+
return undefined;
|
|
383
|
+
supports[flag] = false;
|
|
384
|
+
return notice;
|
|
385
|
+
};
|
|
386
|
+
/** The same for a refusal that is the model's, which only a request that named one can meet. */
|
|
387
|
+
const modelAnswer = (flag, matches, notice) => ({ detail, named }) => {
|
|
388
|
+
if (!named?.refused[flag] || !matches(detail))
|
|
389
|
+
return undefined;
|
|
390
|
+
named.refused[flag] = false;
|
|
391
|
+
return notice(named.name);
|
|
392
|
+
};
|
|
393
|
+
/** An effort refused by value: latched, so `effortFor` sends the next rung down. */
|
|
394
|
+
const effortStepAnswer = ({ detail, named }) => {
|
|
395
|
+
const stepped = named && planEffortStep(detail, named.refused);
|
|
396
|
+
if (!named || !stepped)
|
|
397
|
+
return undefined;
|
|
398
|
+
named.refused.refusedEfforts.add(stepped.value);
|
|
399
|
+
if (stepped.supported)
|
|
400
|
+
named.refused.supportedEfforts = stepped.supported;
|
|
401
|
+
return `${named.name} does not reason at ${stepped.value}; retrying at ${stepped.next}`;
|
|
402
|
+
};
|
|
403
|
+
/** Fields the endpoint has never heard of, where they are droppable and not refused already. */
|
|
404
|
+
const droppedFieldsAnswer = ({ detail, named, droppable }) => {
|
|
405
|
+
if (!named || droppable.size === 0)
|
|
406
|
+
return undefined;
|
|
407
|
+
const fields = unknownFields(detail).filter((field) => droppable.has(field) && !named.refused.refusedFields.has(field));
|
|
408
|
+
if (!fields.length)
|
|
409
|
+
return undefined;
|
|
410
|
+
for (const field of fields)
|
|
411
|
+
named.refused.refusedFields.add(field);
|
|
412
|
+
return `${named.name} does not take ${fields.join(", ")}; retrying without ${fields.length > 1 ? "them" : "it"}`;
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* Every refusal `negotiate` answers, in the order it asks. The first to answer is the only one.
|
|
416
|
+
*
|
|
417
|
+
* The order is behaviour rather than layout: one error text can satisfy two of these — a message
|
|
418
|
+
* about `reasoning_effort` is a refused field to one and a refused value to the next — and the
|
|
419
|
+
* earlier row is the reading that wins.
|
|
420
|
+
*/
|
|
421
|
+
const ANSWERS = [
|
|
422
|
+
endpointAnswer("strictSchemas", isGrammarError, "server could not build a grammar; retrying without pattern/format"),
|
|
423
|
+
endpointAnswer("usageInStream", (detail) => REJECTS_USAGE.test(detail), "server rejected stream_options; token counts unavailable"),
|
|
424
|
+
modelAnswer("reasoningEffort", rejectsEffort, (name) => `${name} does not take a reasoning effort; retrying without one`),
|
|
425
|
+
effortStepAnswer,
|
|
426
|
+
modelAnswer("legacyTokenLimit", wantsCompletionLimit, (name) => `${name} wants max_completion_tokens; retrying with the limit spelled that way`),
|
|
427
|
+
modelAnswer("chosenTemperature", refusesChosenTemperature, (name) => `${name} takes only its own temperature; retrying without ours`),
|
|
428
|
+
modelAnswer("structuredOutput", rejectsResponseFormat, (name) => `${name} does not take response_format; asking for JSON in words instead`),
|
|
429
|
+
droppedFieldsAnswer,
|
|
430
|
+
];
|
|
353
431
|
/**
|
|
354
432
|
* Sends a request, re-sending it each time the answer is this endpoint refusing something the
|
|
355
433
|
* request can do without. Returns once the endpoint has answered, or throws if the refusal is
|
|
@@ -386,16 +464,13 @@ const refusesDroppable = (detail, droppable, refused) => droppable.size > 0 &&
|
|
|
386
464
|
*/
|
|
387
465
|
export async function negotiate(supports, send, { produced = { any: false }, onNotice, model: name, droppable } = {}) {
|
|
388
466
|
const optional = new Set(droppable);
|
|
389
|
-
// The name and what it has refused, bound together because the notices below need both. They
|
|
390
|
-
// exist or are absent as one — the second is resolved from the first — but that is a fact
|
|
391
|
-
// about two locals, and narrowing one of those tells TypeScript nothing about the other.
|
|
392
467
|
const named = name === undefined ? undefined : { name, refused: modelCapabilitiesFor(supports, name) };
|
|
393
468
|
const model = named?.refused;
|
|
394
469
|
for (;;) {
|
|
395
470
|
// What this attempt was built with. `capabilitiesFor` and `modelCapabilitiesFor` hand one
|
|
396
471
|
// object per endpoint and per model to everyone on them, so a run starting alongside this
|
|
397
|
-
// one may latch a flag off while this call is in flight — and
|
|
398
|
-
// on
|
|
472
|
+
// one may latch a flag off while this call is in flight — and every answer in `ANSWERS` is
|
|
473
|
+
// guarded on its flag still being set.
|
|
399
474
|
const sent = flagsOf(supports, model);
|
|
400
475
|
try {
|
|
401
476
|
return await send(supports, produced, model);
|
|
@@ -403,47 +478,22 @@ export async function negotiate(supports, send, { produced = { any: false }, onN
|
|
|
403
478
|
catch (error) {
|
|
404
479
|
if (produced.any)
|
|
405
480
|
throw error;
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
named.refused.reasoningEffort = false;
|
|
418
|
-
onNotice?.(`${named.name} does not take a reasoning effort; retrying without one`);
|
|
481
|
+
const refusal = {
|
|
482
|
+
detail: errorMessage(error),
|
|
483
|
+
supports,
|
|
484
|
+
named,
|
|
485
|
+
droppable: optional,
|
|
486
|
+
};
|
|
487
|
+
let notice;
|
|
488
|
+
for (const answer of ANSWERS) {
|
|
489
|
+
notice = answer(refusal);
|
|
490
|
+
if (notice !== undefined)
|
|
491
|
+
break;
|
|
419
492
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
named.refused.supportedEfforts = stepped.supported;
|
|
424
|
-
onNotice?.(`${named.name} does not reason at ${stepped.value}; retrying at ${stepped.next}`);
|
|
425
|
-
}
|
|
426
|
-
else if (named?.refused.legacyTokenLimit && wantsCompletionLimit(detail)) {
|
|
427
|
-
named.refused.legacyTokenLimit = false;
|
|
428
|
-
onNotice?.(`${named.name} wants max_completion_tokens; retrying with the limit spelled that way`);
|
|
429
|
-
}
|
|
430
|
-
else if (named?.refused.chosenTemperature && refusesChosenTemperature(detail)) {
|
|
431
|
-
named.refused.chosenTemperature = false;
|
|
432
|
-
onNotice?.(`${named.name} takes only its own temperature; retrying without ours`);
|
|
433
|
-
}
|
|
434
|
-
else if (named?.refused.structuredOutput && rejectsResponseFormat(detail)) {
|
|
435
|
-
named.refused.structuredOutput = false;
|
|
436
|
-
onNotice?.(`${named.name} does not take response_format; asking for JSON in words instead`);
|
|
437
|
-
}
|
|
438
|
-
else if (named && refusesDroppable(detail, optional, named.refused)) {
|
|
439
|
-
const fields = unknownFields(detail).filter((field) => optional.has(field) && !named.refused.refusedFields.has(field));
|
|
440
|
-
for (const field of fields)
|
|
441
|
-
named.refused.refusedFields.add(field);
|
|
442
|
-
onNotice?.(`${named.name} does not take ${fields.join(", ")}; retrying without ${fields.length > 1 ? "them" : "it"}`);
|
|
443
|
-
}
|
|
444
|
-
else if (flagsOf(supports, model).every((flag, index) => flag === sent[index])) {
|
|
493
|
+
if (notice !== undefined)
|
|
494
|
+
onNotice?.(notice);
|
|
495
|
+
else if (flagsOf(supports, model).every((flag, index) => flag === sent[index]))
|
|
445
496
|
throw error;
|
|
446
|
-
}
|
|
447
497
|
// Otherwise the refusal was answered by whoever got there first, and this attempt was
|
|
448
498
|
// built before the answer existed. Two runs opening on a fresh llama.cpp box both get the
|
|
449
499
|
// grammar error; the first latches it off and re-sends, and the second used to find the
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import OpenAI from "openai";
|
|
3
|
+
import { isPositive } from "./guards.js";
|
|
3
4
|
import { scoped } from "./scope.js";
|
|
4
5
|
/**
|
|
5
6
|
* The SDK insists on a non-empty key even where the server will not look at it. This is what it
|
|
@@ -7,15 +8,14 @@ import { scoped } from "./scope.js";
|
|
|
7
8
|
* not — a caller must not let a local endpoint silently borrow the key meant for a paid one.
|
|
8
9
|
*/
|
|
9
10
|
export const NO_KEY = "agent-core";
|
|
11
|
+
/** A wait in the SDK's spelling: milliseconds, and `undefined` where zero or less means no limit. */
|
|
12
|
+
const limitMs = (seconds) => (seconds > 0 ? seconds * 1000 : undefined);
|
|
10
13
|
/**
|
|
11
14
|
* Zero, less, or absent means no limit, which the SDK spells as `undefined`.
|
|
12
15
|
*
|
|
13
16
|
* @param config Read for `requestTimeoutSeconds` alone.
|
|
14
17
|
*/
|
|
15
|
-
export const timeoutMs = (config) =>
|
|
16
|
-
const seconds = config.requestTimeoutSeconds ?? 0;
|
|
17
|
-
return seconds > 0 ? seconds * 1000 : undefined;
|
|
18
|
-
};
|
|
18
|
+
export const timeoutMs = (config) => limitMs(config.requestTimeoutSeconds ?? 0);
|
|
19
19
|
/**
|
|
20
20
|
* A client per endpoint, made once and kept.
|
|
21
21
|
*
|
|
@@ -92,9 +92,8 @@ const evict = () => {
|
|
|
92
92
|
*/
|
|
93
93
|
export function configureClients(options = {}) {
|
|
94
94
|
for (const [name, value] of Object.entries(options)) {
|
|
95
|
-
if (
|
|
95
|
+
if (isPositive(value))
|
|
96
96
|
poolLimits()[name] = value;
|
|
97
|
-
}
|
|
98
97
|
}
|
|
99
98
|
evict();
|
|
100
99
|
return { ...poolLimits() };
|
|
@@ -111,7 +110,7 @@ export const firstTokenMs = (config) => {
|
|
|
111
110
|
const idle = timeoutMs(config);
|
|
112
111
|
return idle === undefined ? undefined : idle * FIRST_TOKEN_FACTOR;
|
|
113
112
|
}
|
|
114
|
-
return config.firstTokenSeconds
|
|
113
|
+
return limitMs(config.firstTokenSeconds);
|
|
115
114
|
};
|
|
116
115
|
/**
|
|
117
116
|
* The client for an endpoint, built once and kept.
|
|
@@ -163,7 +162,7 @@ const CONTEXT_KEYS = [
|
|
|
163
162
|
"context_window",
|
|
164
163
|
"n_ctx",
|
|
165
164
|
];
|
|
166
|
-
const positive = (value) => (
|
|
165
|
+
const positive = (value) => (isPositive(value) ? value : 0);
|
|
167
166
|
/**
|
|
168
167
|
* A listing entry's window, and whether it is only the one the model was trained with.
|
|
169
168
|
*
|
|
@@ -242,6 +241,8 @@ const misses = scoped(() => new Map());
|
|
|
242
241
|
* it; see `listings`.
|
|
243
242
|
*/
|
|
244
243
|
export const endpointKey = (config) => JSON.stringify([config.baseUrl, config.apiKey || NO_KEY]);
|
|
244
|
+
/** One model on one endpoint, as the caches below key it: stringified, so neither half runs into the other. */
|
|
245
|
+
const modelKey = (endpoint, model) => JSON.stringify([endpoint, model]);
|
|
245
246
|
/**
|
|
246
247
|
* `endpointKey` hashed, for the remembered facts that can leave the process.
|
|
247
248
|
*
|
|
@@ -328,7 +329,7 @@ export async function servedWindow(config) {
|
|
|
328
329
|
const endpoint = endpointKey(config);
|
|
329
330
|
if (unserved().has(endpoint))
|
|
330
331
|
return 0;
|
|
331
|
-
const key =
|
|
332
|
+
const key = modelKey(endpoint, config.model);
|
|
332
333
|
const known = served().get(key);
|
|
333
334
|
if (known && (known.window > 0 || Date.now() - known.at < poolLimits().listingMissMs))
|
|
334
335
|
return known.window;
|
|
@@ -424,7 +425,7 @@ export async function contextLimitFor(config, declared = 0) {
|
|
|
424
425
|
// context meter and, in a consumer that compacts on it, compaction: the session then runs at
|
|
425
426
|
// the window instead of under it and fails against the endpoint's own refusal.
|
|
426
427
|
if (!listed()) {
|
|
427
|
-
const missKey =
|
|
428
|
+
const missKey = modelKey(key, config.model);
|
|
428
429
|
const asked = misses().get(missKey);
|
|
429
430
|
// Asked again, but not on every call. A model that is never coming answers the same zero
|
|
430
431
|
// however often the endpoint is asked, and a caller sizing a window per turn pays a round
|
package/dist/compaction.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { consult, notify, turnMessages, } from "./hooks.js";
|
|
1
|
+
import { consult, notify, textOf, turnMessages, } from "./hooks.js";
|
|
2
2
|
import { messageTokens } from "./retry.js";
|
|
3
3
|
import { ask } from "./side-task.js";
|
|
4
4
|
import { holdsDefinitions } from "./tool-loading.js";
|
|
@@ -24,12 +24,6 @@ export const SUMMARY_PROMPT = "You maintain the running memory of a long convers
|
|
|
24
24
|
* How a summary message opens, which is also how `planCompaction` knows one from a system prompt.
|
|
25
25
|
*/
|
|
26
26
|
export const SUMMARY_LEAD = "Summary of the earlier part of this conversation, which is no longer shown in full:\n\n";
|
|
27
|
-
/** A message's content as plain text: parts joined, anything but text left out. */
|
|
28
|
-
const textOf = (content) => typeof content === "string"
|
|
29
|
-
? content
|
|
30
|
-
: Array.isArray(content)
|
|
31
|
-
? content.map((part) => ("text" in part ? part.text : "")).join(" ")
|
|
32
|
-
: "";
|
|
33
27
|
/** What the summariser reads for one message: its text and the calls it made. */
|
|
34
28
|
const messageText = (message) => {
|
|
35
29
|
const calls = "tool_calls" in message && message.tool_calls
|
|
@@ -98,6 +92,8 @@ export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}
|
|
|
98
92
|
}
|
|
99
93
|
return out ?? messages;
|
|
100
94
|
}
|
|
95
|
+
/** An index held between the start of the transcript and `most`, for a caller's `from` that is neither. */
|
|
96
|
+
const clamp = (index, most) => Math.min(Math.max(index, 0), most);
|
|
101
97
|
/**
|
|
102
98
|
* Where to fold a transcript that has grown into its window, or `undefined` when it should not be.
|
|
103
99
|
*
|
|
@@ -133,7 +129,7 @@ export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT,
|
|
|
133
129
|
if (cost < limit * compactAt)
|
|
134
130
|
return undefined;
|
|
135
131
|
const head = systemHead(messages);
|
|
136
|
-
const from =
|
|
132
|
+
const from = clamp(givenFrom ?? head.from, messages.length);
|
|
137
133
|
const previous = givenPrevious ?? head.previous;
|
|
138
134
|
let cut = messages.length;
|
|
139
135
|
let after;
|
|
@@ -256,7 +252,7 @@ export async function runCompaction(messages, plan, summarise, { hooks, forced =
|
|
|
256
252
|
export function applyCompaction(messages, record, { from } = {}) {
|
|
257
253
|
if (!record?.summary.trim())
|
|
258
254
|
return messages;
|
|
259
|
-
const head =
|
|
255
|
+
const head = clamp(from ?? systemHead(messages).from, record.through);
|
|
260
256
|
return [
|
|
261
257
|
...messages.slice(0, head).filter((message) => !isSummary(message)),
|
|
262
258
|
summaryMessage(record.summary),
|
package/dist/continuation.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import OpenAI from "openai";
|
|
2
|
-
import { type Capabilities
|
|
3
|
-
import { type RunTurnOptions } from "./run-turn.ts";
|
|
1
|
+
import type OpenAI from "openai";
|
|
2
|
+
import { type Capabilities } from "./capabilities.ts";
|
|
3
|
+
import { type RequestBuilder, type RunTurnOptions } from "./run-turn.ts";
|
|
4
4
|
import type { Turn } from "./stream.ts";
|
|
5
5
|
/**
|
|
6
6
|
* Picking up an answer the token ceiling cut off, instead of keeping half of it.
|
|
@@ -56,4 +56,4 @@ export declare const isContinuable: (turn: Turn) => boolean;
|
|
|
56
56
|
* @param options `runTurn`'s options, with `model` needed for the latch — without one nothing is
|
|
57
57
|
* latched and each continuation finds out again — and the cap on continuations.
|
|
58
58
|
*/
|
|
59
|
-
export declare function continueTurn(client: OpenAI, supports: Capabilities, request:
|
|
59
|
+
export declare function continueTurn(client: OpenAI, supports: Capabilities, request: RequestBuilder, turn: Turn, { maxContinuations, ...options }?: ContinueTurnOptions): Promise<Turn>;
|
package/dist/continuation.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import OpenAI from "openai";
|
|
2
1
|
import { modelCapabilitiesFor } from "./capabilities.js";
|
|
3
2
|
import { errorMessage } from "./errors.js";
|
|
4
|
-
import { ContextOverflow } from "./retry.js";
|
|
3
|
+
import { ContextOverflow, refusesRequest } from "./retry.js";
|
|
5
4
|
import { runTurn } from "./run-turn.js";
|
|
6
5
|
/**
|
|
7
6
|
* Whether a turn is one a continuation can finish: cut off at the ceiling, with an answer begun
|
|
@@ -79,8 +78,6 @@ function joinUsage(first, next) {
|
|
|
79
78
|
}
|
|
80
79
|
return joined;
|
|
81
80
|
}
|
|
82
|
-
/** Whether a failure is the endpoint refusing the request as written, rather than losing it. */
|
|
83
|
-
const refusesRequest = (error) => error instanceof OpenAI.APIError && (error.status === 400 || error.status === 422);
|
|
84
81
|
/**
|
|
85
82
|
* Carries on an answer the token ceiling cut off, by sending the transcript again with the answer
|
|
86
83
|
* so far as a trailing assistant message, and joins the pieces into one turn.
|
package/dist/events.d.ts
CHANGED
|
@@ -175,6 +175,19 @@ export interface RunEvent {
|
|
|
175
175
|
* a duplicate `seq`, which is the one thing the sequence is for.
|
|
176
176
|
*/
|
|
177
177
|
export type RunEventInput = Pick<RunEvent, "kind"> & Partial<Omit<RunEvent, "kind" | "runId" | "seq">>;
|
|
178
|
+
/**
|
|
179
|
+
* A whole event from what a caller said of it: the empty defaults under it, the run and sequence over it.
|
|
180
|
+
*
|
|
181
|
+
* The one place the defaults are written, so an event the bus stamps, one the loop keeps for its
|
|
182
|
+
* own metrics and the notice a slow watcher is sent cannot come to disagree about what an unset
|
|
183
|
+
* field reads as.
|
|
184
|
+
*
|
|
185
|
+
* @param input What happened. An `at` it carries wins over the one given here.
|
|
186
|
+
* @param runId The run it belongs to. Written after `input`, which gets no say in it.
|
|
187
|
+
* @param seq Its place in that run, the same.
|
|
188
|
+
* @param at When, for an input that does not say.
|
|
189
|
+
*/
|
|
190
|
+
export declare const stamp: (input: RunEventInput, runId: string, seq: number, at: number) => RunEvent;
|
|
178
191
|
/**
|
|
179
192
|
* Forgets a run that will not be emitting `done` — one whose process is tearing down, or whose
|
|
180
193
|
* loop threw where it could not be caught. The sweep gets there on its own; this is for a
|
package/dist/events.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPositive } from "./guards.js";
|
|
1
2
|
import { scoped } from "./scope.js";
|
|
2
3
|
import { LOAD_TOOLS } from "./tool-loading.js";
|
|
3
4
|
/** The numbers a run of the shape this bus was written for wants. */
|
|
@@ -31,11 +32,34 @@ const bus = scoped(() => ({ limits: { ...DEFAULTS }, streams: new Map(), sweepin
|
|
|
31
32
|
export function configureEvents(options = {}) {
|
|
32
33
|
const { limits } = bus();
|
|
33
34
|
for (const [name, value] of Object.entries(options)) {
|
|
34
|
-
if (
|
|
35
|
+
if (isPositive(value))
|
|
35
36
|
limits[name] = value;
|
|
36
37
|
}
|
|
37
38
|
return { ...limits };
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* A whole event from what a caller said of it: the empty defaults under it, the run and sequence over it.
|
|
42
|
+
*
|
|
43
|
+
* The one place the defaults are written, so an event the bus stamps, one the loop keeps for its
|
|
44
|
+
* own metrics and the notice a slow watcher is sent cannot come to disagree about what an unset
|
|
45
|
+
* field reads as.
|
|
46
|
+
*
|
|
47
|
+
* @param input What happened. An `at` it carries wins over the one given here.
|
|
48
|
+
* @param runId The run it belongs to. Written after `input`, which gets no say in it.
|
|
49
|
+
* @param seq Its place in that run, the same.
|
|
50
|
+
* @param at When, for an input that does not say.
|
|
51
|
+
*/
|
|
52
|
+
export const stamp = (input, runId, seq, at) => ({
|
|
53
|
+
at,
|
|
54
|
+
text: "",
|
|
55
|
+
name: "",
|
|
56
|
+
step: "",
|
|
57
|
+
ok: null,
|
|
58
|
+
usage: null,
|
|
59
|
+
...input,
|
|
60
|
+
runId,
|
|
61
|
+
seq,
|
|
62
|
+
});
|
|
39
63
|
const streamFor = ({ streams }, runId) => {
|
|
40
64
|
const existing = streams.get(runId);
|
|
41
65
|
if (existing)
|
|
@@ -114,18 +138,7 @@ export function emit(runId, input) {
|
|
|
114
138
|
const stream = streamFor(held, runId);
|
|
115
139
|
// One clock read, used for both the event and the sweep's bookkeeping.
|
|
116
140
|
const at = Date.now();
|
|
117
|
-
const event =
|
|
118
|
-
at,
|
|
119
|
-
text: "",
|
|
120
|
-
name: "",
|
|
121
|
-
step: "",
|
|
122
|
-
ok: null,
|
|
123
|
-
usage: null,
|
|
124
|
-
...input,
|
|
125
|
-
// After the spread, not before: these are the bus's and a caller does not get a say.
|
|
126
|
-
runId,
|
|
127
|
-
seq: ++stream.seq,
|
|
128
|
-
};
|
|
141
|
+
const event = stamp(input, runId, ++stream.seq, at);
|
|
129
142
|
stream.events.push(event);
|
|
130
143
|
stream.touched = at;
|
|
131
144
|
if (stream.events.length > limits.maxEvents + limits.trimSlack) {
|
|
@@ -228,17 +241,11 @@ async function* watching(held, runId, signal) {
|
|
|
228
241
|
// Inside the gap there is nothing to collide with: those seqs reach no watcher.
|
|
229
242
|
const gap = dropped;
|
|
230
243
|
dropped = 0;
|
|
231
|
-
yield {
|
|
232
|
-
runId,
|
|
233
|
-
seq: event.seq - 1,
|
|
234
|
-
at: event.at,
|
|
244
|
+
yield stamp({
|
|
235
245
|
kind: "notice",
|
|
236
246
|
text: `${gap} event(s) dropped: this watcher fell too far behind`,
|
|
237
|
-
name: "",
|
|
238
247
|
step: event.step,
|
|
239
|
-
|
|
240
|
-
usage: null,
|
|
241
|
-
};
|
|
248
|
+
}, runId, event.seq - 1, event.at);
|
|
242
249
|
}
|
|
243
250
|
yield event;
|
|
244
251
|
// `done` is the last event a run will ever have, so the subscription completes rather
|
package/dist/guards.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checks a value read off the wire is put through before anything is read from it.
|
|
3
|
+
*
|
|
4
|
+
* A leaf with no imports, because the modules that need these — a browser-safe `spec`, the schema
|
|
5
|
+
* walk, the tool-call reader, the snapshot importer — otherwise share nothing, and each had grown
|
|
6
|
+
* its own copy.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Whether a value is a plain keyed object: not null, and not an array.
|
|
10
|
+
*
|
|
11
|
+
* @param value Anything, usually just parsed. An array is an object to `typeof` and not to this.
|
|
12
|
+
*/
|
|
13
|
+
export declare const isRecord: (value: unknown) => value is Record<string, unknown>;
|
|
14
|
+
/**
|
|
15
|
+
* Whether a value is a number above zero, which is what a limit has to be to be one.
|
|
16
|
+
*
|
|
17
|
+
* `Infinity` passes and `NaN` does not, so a caller lifting a bound and one handing over a
|
|
18
|
+
* half-built config are told apart without either being asked.
|
|
19
|
+
*
|
|
20
|
+
* @param value Anything, usually a setting that may be absent, zero or mistyped.
|
|
21
|
+
*/
|
|
22
|
+
export declare const isPositive: (value: unknown) => value is number;
|
package/dist/guards.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checks a value read off the wire is put through before anything is read from it.
|
|
3
|
+
*
|
|
4
|
+
* A leaf with no imports, because the modules that need these — a browser-safe `spec`, the schema
|
|
5
|
+
* walk, the tool-call reader, the snapshot importer — otherwise share nothing, and each had grown
|
|
6
|
+
* its own copy.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Whether a value is a plain keyed object: not null, and not an array.
|
|
10
|
+
*
|
|
11
|
+
* @param value Anything, usually just parsed. An array is an object to `typeof` and not to this.
|
|
12
|
+
*/
|
|
13
|
+
export const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
|
+
/**
|
|
15
|
+
* Whether a value is a number above zero, which is what a limit has to be to be one.
|
|
16
|
+
*
|
|
17
|
+
* `Infinity` passes and `NaN` does not, so a caller lifting a bound and one handing over a
|
|
18
|
+
* half-built config are told apart without either being asked.
|
|
19
|
+
*
|
|
20
|
+
* @param value Anything, usually a setting that may be absent, zero or mistyped.
|
|
21
|
+
*/
|
|
22
|
+
export const isPositive = (value) => typeof value === "number" && value > 0;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The points in a session a hook can be bound to, apart from everything that runs one.
|
|
3
|
+
*
|
|
4
|
+
* A leaf with no imports, because two modules need these lists and only one of them may load
|
|
5
|
+
* `node:crypto`: `hooks.ts` hashes a turn into the uuid a memory server dedups on, and `spec.ts`
|
|
6
|
+
* has to stay importable in a browser. Before this each kept its own copy, with a test holding
|
|
7
|
+
* one pair together and nothing holding the other.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* A point in a session a hook can be bound to. Named after Claude Code's hooks of the same shape
|
|
11
|
+
* — `sessionStart` (SessionStart), `beforeTurn` (UserPromptSubmit), `afterTurn` (Stop),
|
|
12
|
+
* `beforeCompact` (PreCompact), `sessionEnd` (SessionEnd) — plus `sessionDelete`, for when the host
|
|
13
|
+
* deletes a session's record.
|
|
14
|
+
*/
|
|
15
|
+
export type HookEvent = "sessionStart" | "beforeTurn" | "afterTurn" | "beforeCompact" | "sessionEnd" | "sessionDelete";
|
|
16
|
+
/** Every event a hook can be bound to, in the order a session meets them. */
|
|
17
|
+
export declare const HOOK_EVENTS: readonly HookEvent[];
|
|
18
|
+
/**
|
|
19
|
+
* The events whose hooks run before a request, and so the only ones whose output can reach it.
|
|
20
|
+
* Anything later runs once the model has already answered.
|
|
21
|
+
*/
|
|
22
|
+
export declare const INJECT_EVENTS: ReadonlySet<HookEvent>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The points in a session a hook can be bound to, apart from everything that runs one.
|
|
3
|
+
*
|
|
4
|
+
* A leaf with no imports, because two modules need these lists and only one of them may load
|
|
5
|
+
* `node:crypto`: `hooks.ts` hashes a turn into the uuid a memory server dedups on, and `spec.ts`
|
|
6
|
+
* has to stay importable in a browser. Before this each kept its own copy, with a test holding
|
|
7
|
+
* one pair together and nothing holding the other.
|
|
8
|
+
*/
|
|
9
|
+
/** Every event a hook can be bound to, in the order a session meets them. */
|
|
10
|
+
export const HOOK_EVENTS = [
|
|
11
|
+
"sessionStart",
|
|
12
|
+
"beforeTurn",
|
|
13
|
+
"afterTurn",
|
|
14
|
+
"beforeCompact",
|
|
15
|
+
"sessionEnd",
|
|
16
|
+
"sessionDelete",
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* The events whose hooks run before a request, and so the only ones whose output can reach it.
|
|
20
|
+
* Anything later runs once the model has already answered.
|
|
21
|
+
*/
|
|
22
|
+
export const INJECT_EVENTS = new Set(["sessionStart", "beforeTurn"]);
|