@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/side-task.d.ts
CHANGED
|
@@ -126,6 +126,11 @@ export declare function tryAsk<T>(label: string, run: () => Promise<T>, { onNoti
|
|
|
126
126
|
* Models are asked for JSON and often answer with prose around it, or a fenced block. Pull out
|
|
127
127
|
* the first array or object rather than failing the task over a wrapper.
|
|
128
128
|
*
|
|
129
|
+
* What is pulled out is read the way a tool call's arguments are: as written where that parses,
|
|
130
|
+
* and otherwise with the almost-JSON a local model writes repaired — single quotes, `True` and
|
|
131
|
+
* `None`, bare keys, a trailing comma. A small model asked for a list gets these wrong as often in
|
|
132
|
+
* a reply as in a call, and a side task that fails over one costs the run a second request.
|
|
133
|
+
*
|
|
129
134
|
* @param text The reply, fences and prose included. Nothing parseable gives `undefined`.
|
|
130
135
|
*/
|
|
131
136
|
export declare function parseJson<T>(text: string): T | undefined;
|
package/dist/side-task.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
|
-
import { capabilitiesFor, effortFor, modelCapabilitiesFor, negotiate, } from "./capabilities.js";
|
|
2
|
+
import { capabilitiesFor, ceilingAndTemperature, effortFor, modelCapabilitiesFor, negotiate, } from "./capabilities.js";
|
|
3
3
|
import { endpointId, getClient } from "./client.js";
|
|
4
4
|
import { errorMessage } from "./errors.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { refusesRequest } from "./retry.js";
|
|
6
|
+
import { relaxSchema, sanitizeSchema } from "./schema-compat.js";
|
|
7
7
|
import { scoped } from "./scope.js";
|
|
8
8
|
import { stripThinking } from "./thinking.js";
|
|
9
|
+
import { looseJson } from "./tool-calls.js";
|
|
9
10
|
/**
|
|
10
11
|
* One-shot calls that support a run without being one: picking tools, naming a session,
|
|
11
12
|
* summarising a transcript, proposing follow-ups. They share a shape — small prompt, short
|
|
@@ -47,25 +48,6 @@ export const hintKey = (endpoint, model) => JSON.stringify([endpoint, model]);
|
|
|
47
48
|
export const refusedHints = () => noHints();
|
|
48
49
|
/** Test seam, alongside `resetClients` and `resetAll`: forget which models refused the hints. */
|
|
49
50
|
export const resetHints = () => noHints().clear();
|
|
50
|
-
/**
|
|
51
|
-
* Whether a failure is the server complaining about the request, rather than failing to answer.
|
|
52
|
-
*
|
|
53
|
-
* The retry below used to catch everything, so an aborted first call — or a connection that
|
|
54
|
-
* never landed — latched the hints off for the life of the process and every later side task
|
|
55
|
-
* paid for it by burning a whole budget on deliberation. Narrowing that to any 4xx was still
|
|
56
|
-
* too wide: 401, 404 and 429 are all 4xx and none of them is about the fields. A 429 was the
|
|
57
|
-
* worst of them, because the retry then re-sent the whole request immediately — doubling the
|
|
58
|
-
* rate against a server that had just asked for less of it — and `isTransient` accepts exactly
|
|
59
|
-
* that status, so the two halves of this package disagreed about one error.
|
|
60
|
-
*
|
|
61
|
-
* 400 and 422 are what a server says when it read the body and disliked it. Everything else
|
|
62
|
-
* is left to the caller's own retry.
|
|
63
|
-
*/
|
|
64
|
-
function rejectedTheRequest(error) {
|
|
65
|
-
if (!(error instanceof OpenAI.APIError) || isTransient(error))
|
|
66
|
-
return false;
|
|
67
|
-
return error.status === 400 || error.status === 422;
|
|
68
|
-
}
|
|
69
51
|
/**
|
|
70
52
|
* Runs a side task and returns the reply text, thinking stripped. Throws like any request.
|
|
71
53
|
*
|
|
@@ -98,16 +80,10 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
|
|
|
98
80
|
return getClient(config).chat.completions.create({
|
|
99
81
|
model,
|
|
100
82
|
// The reasoning models want the ceiling spelled the other way, and they are exactly the
|
|
101
|
-
// models a side task most wants to stop deliberating.
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
? { max_completion_tokens: maxTokens }
|
|
106
|
-
: { max_tokens: maxTokens }
|
|
107
|
-
: {}),
|
|
108
|
-
// One that will only run at the temperature it was built with is sent none: a side task
|
|
109
|
-
// wants the same answer twice, and 1.0 from that model is as close as it gets.
|
|
110
|
-
...(refused && !refused.chosenTemperature ? {} : { temperature }),
|
|
83
|
+
// models a side task most wants to stop deliberating. One that will only run at its own
|
|
84
|
+
// temperature is sent none: a side task wants the same answer twice, and 1.0 from that
|
|
85
|
+
// model is as close as it gets.
|
|
86
|
+
...ceilingAndTemperature(refused, maxTokens, temperature),
|
|
111
87
|
messages: [
|
|
112
88
|
{ role: "system", content: system },
|
|
113
89
|
{ role: "user", content: user },
|
|
@@ -142,7 +118,7 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
|
|
|
142
118
|
// does not offer as `none`. Or a 400 about something else entirely, which is why the
|
|
143
119
|
// notice says what was tried rather than what was wrong.
|
|
144
120
|
const effort = sentEffort;
|
|
145
|
-
if (!(hints || effort) || !
|
|
121
|
+
if (!(hints || effort) || !refusesRequest(error))
|
|
146
122
|
throw error;
|
|
147
123
|
onNotice?.(level
|
|
148
124
|
? `${model} rejected a request carrying a reasoning effort; retrying without it`
|
|
@@ -189,13 +165,8 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
|
|
|
189
165
|
* @param options A side task's options, plus the schema's `name` and whether it is `strict`.
|
|
190
166
|
*/
|
|
191
167
|
export async function askJson(config, model, system, user, schema, { name = "answer", strict = true, ...options } = {}) {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
function: { name, parameters },
|
|
195
|
-
});
|
|
196
|
-
const [sanitized] = sanitizeTools([tool(schema)]);
|
|
197
|
-
const shapeOf = (definition) => definition?.type === "function" ? (definition.function.parameters ?? {}) : {};
|
|
198
|
-
const instruction = `${system}\n\nReply with JSON alone, matching this JSON Schema:\n${JSON.stringify(shapeOf(sanitized))}`;
|
|
168
|
+
const sanitized = sanitizeSchema(schema);
|
|
169
|
+
const instruction = `${system}\n\nReply with JSON alone, matching this JSON Schema:\n${JSON.stringify(sanitized)}`;
|
|
199
170
|
const reply = await complete(config, model, instruction, user, options, (supports, refused) => refused.structuredOutput
|
|
200
171
|
? {
|
|
201
172
|
response_format: {
|
|
@@ -203,7 +174,7 @@ export async function askJson(config, model, system, user, schema, { name = "ans
|
|
|
203
174
|
json_schema: {
|
|
204
175
|
name,
|
|
205
176
|
strict,
|
|
206
|
-
schema:
|
|
177
|
+
schema: supports.strictSchemas ? sanitized : relaxSchema(sanitized),
|
|
207
178
|
},
|
|
208
179
|
},
|
|
209
180
|
}
|
|
@@ -235,6 +206,11 @@ export async function tryAsk(label, run, { onNotice } = {}) {
|
|
|
235
206
|
* Models are asked for JSON and often answer with prose around it, or a fenced block. Pull out
|
|
236
207
|
* the first array or object rather than failing the task over a wrapper.
|
|
237
208
|
*
|
|
209
|
+
* What is pulled out is read the way a tool call's arguments are: as written where that parses,
|
|
210
|
+
* and otherwise with the almost-JSON a local model writes repaired — single quotes, `True` and
|
|
211
|
+
* `None`, bare keys, a trailing comma. A small model asked for a list gets these wrong as often in
|
|
212
|
+
* a reply as in a call, and a side task that fails over one costs the run a second request.
|
|
213
|
+
*
|
|
238
214
|
* @param text The reply, fences and prose included. Nothing parseable gives `undefined`.
|
|
239
215
|
*/
|
|
240
216
|
export function parseJson(text) {
|
|
@@ -246,12 +222,7 @@ export function parseJson(text) {
|
|
|
246
222
|
const end = Math.max(body.lastIndexOf("]"), body.lastIndexOf("}"));
|
|
247
223
|
if (end <= start)
|
|
248
224
|
return undefined;
|
|
249
|
-
|
|
250
|
-
return JSON.parse(body.slice(start, end + 1));
|
|
251
|
-
}
|
|
252
|
-
catch {
|
|
253
|
-
return undefined;
|
|
254
|
-
}
|
|
225
|
+
return looseJson(body.slice(start, end + 1));
|
|
255
226
|
}
|
|
256
227
|
/**
|
|
257
228
|
* Strips the quoting and list punctuation models decorate short answers with.
|
package/dist/snapshot.d.ts
CHANGED
|
@@ -16,8 +16,8 @@ export interface ModelSnapshot {
|
|
|
16
16
|
refusedFields: string[];
|
|
17
17
|
structuredOutput: boolean;
|
|
18
18
|
/**
|
|
19
|
-
* Continues a trailing assistant message.
|
|
20
|
-
* reads as not refused.
|
|
19
|
+
* Continues a trailing assistant message. Always written; a stored snapshot from before it was
|
|
20
|
+
* latched lacks it, which `importCapabilities` reads as not refused.
|
|
21
21
|
*/
|
|
22
22
|
assistantPrefill: boolean;
|
|
23
23
|
/** Takes the no-thinking hints `ask` sends. */
|
package/dist/snapshot.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { capabilitiesById, knownCapabilities, modelCapabilitiesFor } from "./capabilities.js";
|
|
1
|
+
import { capabilitiesById, knownCapabilities, MODEL_FLAGS, modelCapabilitiesFor, OPTIMISTIC_MODEL, } from "./capabilities.js";
|
|
2
|
+
import { isRecord } from "./guards.js";
|
|
2
3
|
import { hintKey, refusedHints } from "./side-task.js";
|
|
3
4
|
/**
|
|
4
5
|
* What endpoints and models refused, carried across a restart.
|
|
@@ -11,23 +12,23 @@ import { hintKey, refusedHints } from "./side-task.js";
|
|
|
11
12
|
/** The version `importCapabilities` accepts. Raised when what is latched changes shape. */
|
|
12
13
|
export const CAPABILITY_SNAPSHOT_VERSION = 1;
|
|
13
14
|
const optimisticModel = () => ({
|
|
14
|
-
|
|
15
|
-
legacyTokenLimit: true,
|
|
16
|
-
chosenTemperature: true,
|
|
17
|
-
refusedFields: [],
|
|
18
|
-
structuredOutput: true,
|
|
19
|
-
assistantPrefill: true,
|
|
15
|
+
...OPTIMISTIC_MODEL,
|
|
20
16
|
thinkingHints: true,
|
|
17
|
+
refusedFields: [],
|
|
21
18
|
refusedEfforts: [],
|
|
22
19
|
});
|
|
23
|
-
const refusedAnything = (model) => !model
|
|
24
|
-
!model.legacyTokenLimit ||
|
|
25
|
-
!model.chosenTemperature ||
|
|
20
|
+
const refusedAnything = (model) => MODEL_FLAGS.some((flag) => !model[flag]) ||
|
|
26
21
|
!model.thinkingHints ||
|
|
27
|
-
!model.structuredOutput ||
|
|
28
|
-
!model.assistantPrefill ||
|
|
29
22
|
model.refusedFields.length > 0 ||
|
|
30
23
|
(model.refusedEfforts?.length ?? 0) > 0;
|
|
24
|
+
/** Latches every string in a stored list into a live set, skipping what is not a list of them. */
|
|
25
|
+
const latchInto = (held, stored) => {
|
|
26
|
+
if (!Array.isArray(stored))
|
|
27
|
+
return;
|
|
28
|
+
for (const value of stored)
|
|
29
|
+
if (typeof value === "string")
|
|
30
|
+
held.add(value);
|
|
31
|
+
};
|
|
31
32
|
/**
|
|
32
33
|
* Every refusal this process has latched, as a JSON-safe blob to store and hand back on boot.
|
|
33
34
|
*
|
|
@@ -53,19 +54,15 @@ export function exportCapabilities() {
|
|
|
53
54
|
for (const [id, supports] of knownCapabilities()) {
|
|
54
55
|
const models = {};
|
|
55
56
|
for (const [name, refused] of supports.models) {
|
|
56
|
-
const model =
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// Only alongside a refusal, since on its own a published list latches nothing: the model
|
|
66
|
-
// named it while refusing a rung, and that rung is in `refusedEfforts`.
|
|
67
|
-
...(refused.supportedEfforts ? { supportedEfforts: [...refused.supportedEfforts] } : {}),
|
|
68
|
-
};
|
|
57
|
+
const model = optimisticModel();
|
|
58
|
+
for (const flag of MODEL_FLAGS)
|
|
59
|
+
model[flag] = refused[flag];
|
|
60
|
+
model.refusedFields = [...refused.refusedFields].sort();
|
|
61
|
+
model.refusedEfforts = [...refused.refusedEfforts].sort();
|
|
62
|
+
// Only alongside a refusal, since on its own a published list latches nothing: the model
|
|
63
|
+
// named it while refusing a rung, and that rung is in `refusedEfforts`.
|
|
64
|
+
if (refused.supportedEfforts)
|
|
65
|
+
model.supportedEfforts = [...refused.supportedEfforts];
|
|
69
66
|
if (refusedAnything(model))
|
|
70
67
|
models[name] = model;
|
|
71
68
|
}
|
|
@@ -85,7 +82,6 @@ export function exportCapabilities() {
|
|
|
85
82
|
}
|
|
86
83
|
return { version: CAPABILITY_SNAPSHOT_VERSION, savedAt: new Date().toISOString(), endpoints };
|
|
87
84
|
}
|
|
88
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
89
85
|
/**
|
|
90
86
|
* Latches what a stored snapshot says was refused, on top of whatever this process has learned.
|
|
91
87
|
*
|
|
@@ -124,22 +120,10 @@ export function importCapabilities(snapshot) {
|
|
|
124
120
|
if (!isRecord(model))
|
|
125
121
|
continue;
|
|
126
122
|
const refused = modelCapabilitiesFor(supports, name);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (model.chosenTemperature === false)
|
|
132
|
-
refused.chosenTemperature = false;
|
|
133
|
-
if (model.structuredOutput === false)
|
|
134
|
-
refused.structuredOutput = false;
|
|
135
|
-
if (model.assistantPrefill === false)
|
|
136
|
-
refused.assistantPrefill = false;
|
|
137
|
-
if (Array.isArray(model.refusedEfforts)) {
|
|
138
|
-
for (const effort of model.refusedEfforts) {
|
|
139
|
-
if (typeof effort === "string")
|
|
140
|
-
refused.refusedEfforts.add(effort);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
123
|
+
for (const flag of MODEL_FLAGS)
|
|
124
|
+
if (model[flag] === false)
|
|
125
|
+
refused[flag] = false;
|
|
126
|
+
latchInto(refused.refusedEfforts, model.refusedEfforts);
|
|
143
127
|
// Replaced rather than merged: two lists of what one model takes are two readings of the
|
|
144
128
|
// same fact, and the stored one is at least as recent as an empty absent.
|
|
145
129
|
if (Array.isArray(model.supportedEfforts)) {
|
|
@@ -147,12 +131,7 @@ export function importCapabilities(snapshot) {
|
|
|
147
131
|
if (listed.length)
|
|
148
132
|
refused.supportedEfforts = listed;
|
|
149
133
|
}
|
|
150
|
-
|
|
151
|
-
for (const field of model.refusedFields) {
|
|
152
|
-
if (typeof field === "string")
|
|
153
|
-
refused.refusedFields.add(field);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
134
|
+
latchInto(refused.refusedFields, model.refusedFields);
|
|
156
135
|
if (model.thinkingHints === false)
|
|
157
136
|
refusedHints().add(hintKey(id, name));
|
|
158
137
|
}
|
package/dist/spec.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AgentConfig, Endpoint } from "./config.ts";
|
|
2
|
-
import type
|
|
2
|
+
import { type HookEvent } from "./hook-events.ts";
|
|
3
3
|
/**
|
|
4
4
|
* A JSON document that defines an agent, and the rules for reading one.
|
|
5
5
|
*
|
|
@@ -10,11 +10,12 @@ import type { HookEvent } from "./hooks.ts";
|
|
|
10
10
|
* real ceiling of zero* in the third. This is the shape they can all write and all read, argued
|
|
11
11
|
* out in `docs/agent-spec.md` before any of it was code.
|
|
12
12
|
*
|
|
13
|
-
* Nothing else in `src/` imports this module, and this module imports nothing
|
|
14
|
-
*
|
|
15
|
-
* it
|
|
16
|
-
* where an agent comes from. It is also
|
|
17
|
-
* and `hooks.ts` are not: a host validating
|
|
13
|
+
* Nothing else in `src/` imports this module, and this module imports nothing that needs Node:
|
|
14
|
+
* types, and leaves with no imports of their own. That is the same seam `config.ts` draws — the
|
|
15
|
+
* loop takes the parts it reads, not a config object — and it is what lets the document travel
|
|
16
|
+
* between hosts without the loop growing an opinion about where an agent comes from. It is also
|
|
17
|
+
* why this is importable in a browser, where `client.ts` and `hooks.ts` are not: a host validating
|
|
18
|
+
* a pasted document in a form needs no `node:crypto`.
|
|
18
19
|
*/
|
|
19
20
|
/** The token a document declares itself with. A loader refuses anything else. */
|
|
20
21
|
export declare const AGENT_SPEC = "cubicecho.agent/1";
|
|
@@ -29,12 +30,11 @@ export declare const AGENT_SPEC_VERSION = 1;
|
|
|
29
30
|
*/
|
|
30
31
|
export declare const AGENT_TASKS: readonly ["compaction", "toolSelect", "title", "followups"];
|
|
31
32
|
/**
|
|
32
|
-
* Every event a hook may be bound to
|
|
33
|
+
* Every event a hook may be bound to: `HOOK_EVENTS`, under the name a document's reader knows.
|
|
33
34
|
*
|
|
34
|
-
*
|
|
35
|
-
* `createHash` with it, into a file whose whole point is that a
|
|
36
|
-
*
|
|
37
|
-
* list and `HOOK_EVENTS` are the same list so an added one cannot drift quietly.
|
|
35
|
+
* The same array, not a copy. It comes from `hook-events.ts` rather than `hooks.ts` because that
|
|
36
|
+
* module's `createHash` would otherwise come with it, into a file whose whole point is that a
|
|
37
|
+
* browser can load it.
|
|
38
38
|
*/
|
|
39
39
|
export declare const SPEC_EVENTS: readonly HookEvent[];
|
|
40
40
|
/** One agent, as a document. Every field but `spec` is optional, because a layer says only what it changes. */
|
|
@@ -163,7 +163,6 @@ export interface AgentHook {
|
|
|
163
163
|
/** `false` keeps the hook in the document without running it. */
|
|
164
164
|
enabled?: boolean;
|
|
165
165
|
}
|
|
166
|
-
/** One bundled MCP server, as far as this package reads it. */
|
|
167
166
|
/** A bundled MCP server, validated only as far as a round trip needs and otherwise passed through. */
|
|
168
167
|
export interface SpecServer {
|
|
169
168
|
id?: string;
|
package/dist/spec.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isRecord } from "./guards.js";
|
|
2
|
+
import { HOOK_EVENTS, INJECT_EVENTS } from "./hook-events.js";
|
|
1
3
|
/**
|
|
2
4
|
* A JSON document that defines an agent, and the rules for reading one.
|
|
3
5
|
*
|
|
@@ -8,11 +10,12 @@
|
|
|
8
10
|
* real ceiling of zero* in the third. This is the shape they can all write and all read, argued
|
|
9
11
|
* out in `docs/agent-spec.md` before any of it was code.
|
|
10
12
|
*
|
|
11
|
-
* Nothing else in `src/` imports this module, and this module imports nothing
|
|
12
|
-
*
|
|
13
|
-
* it
|
|
14
|
-
* where an agent comes from. It is also
|
|
15
|
-
* and `hooks.ts` are not: a host validating
|
|
13
|
+
* Nothing else in `src/` imports this module, and this module imports nothing that needs Node:
|
|
14
|
+
* types, and leaves with no imports of their own. That is the same seam `config.ts` draws — the
|
|
15
|
+
* loop takes the parts it reads, not a config object — and it is what lets the document travel
|
|
16
|
+
* between hosts without the loop growing an opinion about where an agent comes from. It is also
|
|
17
|
+
* why this is importable in a browser, where `client.ts` and `hooks.ts` are not: a host validating
|
|
18
|
+
* a pasted document in a form needs no `node:crypto`.
|
|
16
19
|
*/
|
|
17
20
|
/** The token a document declares itself with. A loader refuses anything else. */
|
|
18
21
|
export const AGENT_SPEC = "cubicecho.agent/1";
|
|
@@ -27,23 +30,13 @@ export const AGENT_SPEC_VERSION = 1;
|
|
|
27
30
|
*/
|
|
28
31
|
export const AGENT_TASKS = ["compaction", "toolSelect", "title", "followups"];
|
|
29
32
|
/**
|
|
30
|
-
* Every event a hook may be bound to
|
|
33
|
+
* Every event a hook may be bound to: `HOOK_EVENTS`, under the name a document's reader knows.
|
|
31
34
|
*
|
|
32
|
-
*
|
|
33
|
-
* `createHash` with it, into a file whose whole point is that a
|
|
34
|
-
*
|
|
35
|
-
* list and `HOOK_EVENTS` are the same list so an added one cannot drift quietly.
|
|
35
|
+
* The same array, not a copy. It comes from `hook-events.ts` rather than `hooks.ts` because that
|
|
36
|
+
* module's `createHash` would otherwise come with it, into a file whose whole point is that a
|
|
37
|
+
* browser can load it.
|
|
36
38
|
*/
|
|
37
|
-
export const SPEC_EVENTS =
|
|
38
|
-
"sessionStart",
|
|
39
|
-
"beforeTurn",
|
|
40
|
-
"afterTurn",
|
|
41
|
-
"beforeCompact",
|
|
42
|
-
"sessionEnd",
|
|
43
|
-
"sessionDelete",
|
|
44
|
-
];
|
|
45
|
-
/** The events whose output can still reach a request, so the only ones `inject` means anything on. */
|
|
46
|
-
const SPEC_INJECT = new Set(["sessionStart", "beforeTurn"]);
|
|
39
|
+
export const SPEC_EVENTS = HOOK_EVENTS;
|
|
47
40
|
/** Body fields the loop owns. `buildBody` would overwrite them anyway; better to say so on import. */
|
|
48
41
|
const RESERVED_BODY = ["model", "messages", "stream", "tools"];
|
|
49
42
|
/**
|
|
@@ -62,7 +55,6 @@ export const RESOLVED_DEFAULTS = {
|
|
|
62
55
|
maxToolIterations: 20,
|
|
63
56
|
maxRetries: 0,
|
|
64
57
|
};
|
|
65
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
66
58
|
/** Absent and null are the same thing: a nullable column round-trips through JSON as `null`. */
|
|
67
59
|
const absent = (value) => value === undefined || value === null;
|
|
68
60
|
/** A `${VAR}`-shaped string, which is kept as the literal it is rather than expanded. */
|
|
@@ -138,6 +130,12 @@ function parseEndpoint(report, path, value) {
|
|
|
138
130
|
report.drop(`${path}.apiKey`, "is not part of this format — a document carries no credentials");
|
|
139
131
|
return said(endpoint) ? endpoint : undefined;
|
|
140
132
|
}
|
|
133
|
+
/** The three fields a model and a side task both carry, held to the same bounds in each. */
|
|
134
|
+
const parseSampling = (report, path, value) => ({
|
|
135
|
+
maxTokens: report.number(`${path}.maxTokens`, value.maxTokens, 0),
|
|
136
|
+
temperature: report.number(`${path}.temperature`, value.temperature, 0, 2),
|
|
137
|
+
reasoningEffort: report.string(`${path}.reasoningEffort`, value.reasoningEffort),
|
|
138
|
+
});
|
|
141
139
|
function parseModel(report, value) {
|
|
142
140
|
if (absent(value))
|
|
143
141
|
return undefined;
|
|
@@ -159,9 +157,7 @@ function parseModel(report, value) {
|
|
|
159
157
|
}
|
|
160
158
|
return kept({
|
|
161
159
|
model: report.string("model.model", value.model),
|
|
162
|
-
|
|
163
|
-
temperature: report.number("model.temperature", value.temperature, 0, 2),
|
|
164
|
-
reasoningEffort: report.string("model.reasoningEffort", value.reasoningEffort),
|
|
160
|
+
...parseSampling(report, "model", value),
|
|
165
161
|
contextLength: report.number("model.contextLength", value.contextLength, 0),
|
|
166
162
|
extraBody,
|
|
167
163
|
});
|
|
@@ -262,9 +258,7 @@ function parseTasks(report, value) {
|
|
|
262
258
|
tasks[key] = kept({
|
|
263
259
|
model: report.string(`${path}.model`, held.model),
|
|
264
260
|
endpoint: parseEndpoint(report, `${path}.endpoint`, held.endpoint),
|
|
265
|
-
|
|
266
|
-
temperature: report.number(`${path}.temperature`, held.temperature, 0, 2),
|
|
267
|
-
reasoningEffort: report.string(`${path}.reasoningEffort`, held.reasoningEffort),
|
|
261
|
+
...parseSampling(report, path, held),
|
|
268
262
|
});
|
|
269
263
|
}
|
|
270
264
|
return tasks;
|
|
@@ -294,7 +288,7 @@ function parseHooks(report, value, events) {
|
|
|
294
288
|
if (!events.includes(on))
|
|
295
289
|
report.note(`${path}.on`, `"${on}" is never fired by this host`);
|
|
296
290
|
let inject = report.boolean(`${path}.inject`, held.inject);
|
|
297
|
-
if (inject && !
|
|
291
|
+
if (inject && !INJECT_EVENTS.has(on)) {
|
|
298
292
|
report.drop(`${path}.inject`, `"${on}" runs after the model has already answered`);
|
|
299
293
|
inject = undefined;
|
|
300
294
|
}
|
|
@@ -480,20 +474,7 @@ export function parseSpec(document, options = {}) {
|
|
|
480
474
|
};
|
|
481
475
|
}
|
|
482
476
|
/** Merges the layers that named a key, last one winning, and leaves it absent if none did. */
|
|
483
|
-
|
|
484
|
-
const out = {};
|
|
485
|
-
for (const layer of layers) {
|
|
486
|
-
if (!layer)
|
|
487
|
-
continue;
|
|
488
|
-
if (layer.baseUrl !== undefined)
|
|
489
|
-
out.baseUrl = layer.baseUrl;
|
|
490
|
-
if (layer.requestTimeoutSeconds !== undefined)
|
|
491
|
-
out.requestTimeoutSeconds = layer.requestTimeoutSeconds;
|
|
492
|
-
if (layer.firstTokenSeconds !== undefined)
|
|
493
|
-
out.firstTokenSeconds = layer.firstTokenSeconds;
|
|
494
|
-
}
|
|
495
|
-
return out;
|
|
496
|
-
}
|
|
477
|
+
const mergeLayers = (layers) => Object.assign({}, ...layers.map((layer) => kept(layer ?? {})));
|
|
497
478
|
/** An `Endpoint` as the loop takes one. `apiKey` is always empty; see `resolveAgentSpec`. */
|
|
498
479
|
const asEndpoint = (spec) => ({
|
|
499
480
|
baseUrl: spec.baseUrl ?? "",
|
|
@@ -524,7 +505,7 @@ const asEndpoint = (spec) => ({
|
|
|
524
505
|
* @param layers The documents, weakest first: settings, then the agent, then a task, then a step.
|
|
525
506
|
*/
|
|
526
507
|
export function resolveAgentSpec(layers) {
|
|
527
|
-
const endpoint =
|
|
508
|
+
const endpoint = mergeLayers(layers.map((layer) => layer.endpoint));
|
|
528
509
|
const model = {};
|
|
529
510
|
const extraBody = {};
|
|
530
511
|
for (const layer of layers) {
|
|
@@ -541,31 +522,20 @@ export function resolveAgentSpec(layers) {
|
|
|
541
522
|
for (const layer of layers)
|
|
542
523
|
for (const part of layer.prompt ?? [])
|
|
543
524
|
parts.set(part.id, { ...(parts.get(part.id) ?? { id: part.id }), ...part });
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
// and a union of the first two is the first, which loses the scoping the operator asked for.
|
|
552
|
-
if (layer.tools?.servers !== undefined)
|
|
553
|
-
tools.servers = [...layer.tools.servers];
|
|
554
|
-
}
|
|
555
|
-
const retry = {};
|
|
556
|
-
for (const layer of layers) {
|
|
557
|
-
if (layer.retry?.maxRetries !== undefined)
|
|
558
|
-
retry.maxRetries = layer.retry.maxRetries;
|
|
559
|
-
if (layer.retry?.loadingTimeoutSeconds !== undefined)
|
|
560
|
-
retry.loadingTimeoutSeconds = layer.retry.loadingTimeoutSeconds;
|
|
561
|
-
}
|
|
525
|
+
// `servers` is replaced whole rather than unioned: a list, an empty list and no list are three
|
|
526
|
+
// answers, and a union of the first two is the first, which loses the scoping the operator
|
|
527
|
+
// asked for. Copied, so the resolved agent does not share an array with the layer it came from.
|
|
528
|
+
const tools = mergeLayers(layers.map((layer) => layer.tools));
|
|
529
|
+
if (tools.servers)
|
|
530
|
+
tools.servers = [...tools.servers];
|
|
531
|
+
const retry = mergeLayers(layers.map((layer) => layer.retry));
|
|
562
532
|
const merged = new Map();
|
|
563
533
|
for (const layer of layers)
|
|
564
534
|
for (const [key, task] of Object.entries(layer.tasks ?? {}))
|
|
565
535
|
merged.set(key, {
|
|
566
536
|
...(merged.get(key) ?? {}),
|
|
567
537
|
...task,
|
|
568
|
-
endpoint:
|
|
538
|
+
endpoint: mergeLayers([merged.get(key)?.endpoint, task.endpoint]),
|
|
569
539
|
});
|
|
570
540
|
const tasks = {};
|
|
571
541
|
for (const [key, task] of merged) {
|
|
@@ -575,7 +545,7 @@ export function resolveAgentSpec(layers) {
|
|
|
575
545
|
continue;
|
|
576
546
|
tasks[key] = kept({
|
|
577
547
|
model: task.model,
|
|
578
|
-
endpoint: asEndpoint(
|
|
548
|
+
endpoint: asEndpoint(mergeLayers([endpoint, task.endpoint])),
|
|
579
549
|
maxTokens: task.maxTokens,
|
|
580
550
|
temperature: task.temperature,
|
|
581
551
|
reasoningEffort: task.reasoningEffort,
|
|
@@ -599,9 +569,9 @@ export function resolveAgentSpec(layers) {
|
|
|
599
569
|
for (const layer of layers)
|
|
600
570
|
for (const key of layer.requires ?? [])
|
|
601
571
|
requires.add(key);
|
|
602
|
-
const id =
|
|
603
|
-
const name =
|
|
604
|
-
const description =
|
|
572
|
+
const id = layers.findLast((layer) => layer.id)?.id ?? "";
|
|
573
|
+
const name = layers.findLast((layer) => layer.name)?.name;
|
|
574
|
+
const description = layers.findLast((layer) => layer.description)?.description;
|
|
605
575
|
const prompt = [...parts.values()];
|
|
606
576
|
return {
|
|
607
577
|
id,
|
package/dist/stream.d.ts
CHANGED
|
@@ -107,6 +107,13 @@ export interface TurnUsage {
|
|
|
107
107
|
*/
|
|
108
108
|
context?: ContextBreakdown;
|
|
109
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* A usage with nothing counted yet, a fresh object each call since every caller adds into it.
|
|
112
|
+
*
|
|
113
|
+
* Only the four counts every turn has: the optional fields stay absent, which is what says
|
|
114
|
+
* nothing reported them.
|
|
115
|
+
*/
|
|
116
|
+
export declare const noUsage: () => TurnUsage;
|
|
110
117
|
/** One streamed turn, put back together into the shape a loop and a transcript work with. */
|
|
111
118
|
export interface Turn {
|
|
112
119
|
content: string;
|
package/dist/stream.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { EndpointSilent } from "./retry.js";
|
|
2
2
|
import { DEFAULT_FENCES, FenceSplitter } from "./thinking.js";
|
|
3
|
+
/**
|
|
4
|
+
* A usage with nothing counted yet, a fresh object each call since every caller adds into it.
|
|
5
|
+
*
|
|
6
|
+
* Only the four counts every turn has: the optional fields stay absent, which is what says
|
|
7
|
+
* nothing reported them.
|
|
8
|
+
*/
|
|
9
|
+
export const noUsage = () => ({ prompt: 0, completion: 0, total: 0, cached: 0 });
|
|
3
10
|
/**
|
|
4
11
|
* The fields of `timings` a turn reports, under the names `TurnUsage` gives them. A value that is
|
|
5
12
|
* not a finite number is left out rather than guessed at.
|
|
@@ -91,7 +98,7 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
|
|
|
91
98
|
// In arrival order, sorted by index at the end; a call from a server that sent none keeps
|
|
92
99
|
// its place in the order they arrived.
|
|
93
100
|
const calls = [];
|
|
94
|
-
const usage =
|
|
101
|
+
const usage = noUsage();
|
|
95
102
|
// The usage block's own cache count wins over llama.cpp's, which is the same number in the
|
|
96
103
|
// servers that send both; kept apart so the order the two arrive in does not decide.
|
|
97
104
|
let cacheReport;
|
package/dist/thinking.js
CHANGED
|
@@ -110,11 +110,14 @@ export class FenceSplitter {
|
|
|
110
110
|
this.#held = "";
|
|
111
111
|
return parts;
|
|
112
112
|
}
|
|
113
|
+
/** The markers worth looking for: only the close inside a fence, every one outside it. */
|
|
114
|
+
#candidates() {
|
|
115
|
+
return this.#inside ? [this.#inside.close] : this.#markers;
|
|
116
|
+
}
|
|
113
117
|
/** The earliest marker that means something in the current state. */
|
|
114
118
|
#next(text) {
|
|
115
|
-
const candidates = this.#inside ? [this.#inside.close] : this.#markers;
|
|
116
119
|
let best;
|
|
117
|
-
for (const marker of candidates) {
|
|
120
|
+
for (const marker of this.#candidates()) {
|
|
118
121
|
const at = text.indexOf(marker);
|
|
119
122
|
if (at === -1)
|
|
120
123
|
continue;
|
|
@@ -125,9 +128,8 @@ export class FenceSplitter {
|
|
|
125
128
|
}
|
|
126
129
|
/** How much of the end of `text` could be the beginning of a marker. */
|
|
127
130
|
#partialTail(text) {
|
|
128
|
-
const candidates = this.#inside ? [this.#inside.close] : this.#markers;
|
|
129
131
|
let keep = 0;
|
|
130
|
-
for (const marker of candidates)
|
|
132
|
+
for (const marker of this.#candidates())
|
|
131
133
|
for (let length = Math.min(marker.length - 1, text.length); length > keep; length--)
|
|
132
134
|
if (text.endsWith(marker.slice(0, length))) {
|
|
133
135
|
keep = length;
|
package/dist/tokens.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The characters one token is taken to be, where nothing has measured it.
|
|
3
|
+
*
|
|
4
|
+
* The fallback, not the rule: once a turn has come back with a reported prompt count, `runTurn`
|
|
5
|
+
* divides by what that endpoint's model was measured at instead. See `charsPerTokenFor`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const CHARS_PER_TOKEN = 4;
|
|
1
8
|
/**
|
|
2
9
|
* Rough token count. Characters over four, because there is no tokenizer here and there is not
|
|
3
10
|
* going to be one: a server that will not say how big its window is will not lend us its
|
|
@@ -10,8 +17,8 @@
|
|
|
10
17
|
*
|
|
11
18
|
* Its own module because it is the one number several of these agree on, and the module that
|
|
12
19
|
* owns it should not be one that also does something. It was extracted from `side-task` to
|
|
13
|
-
* break a cycle with `retry`;
|
|
14
|
-
* sizing its own prompt
|
|
20
|
+
* break a cycle with `retry`; neither reads it now, but `hooks` does to cap a context block, as
|
|
21
|
+
* does any consumer sizing its own prompt, and a leaf with no imports is the right home for it.
|
|
15
22
|
*
|
|
16
23
|
* @param text Prose or serialised JSON — both counted the same way, which is why JSON reads low.
|
|
17
24
|
*/
|
package/dist/tokens.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The characters one token is taken to be, where nothing has measured it.
|
|
3
|
+
*
|
|
4
|
+
* The fallback, not the rule: once a turn has come back with a reported prompt count, `runTurn`
|
|
5
|
+
* divides by what that endpoint's model was measured at instead. See `charsPerTokenFor`.
|
|
6
|
+
*/
|
|
7
|
+
export const CHARS_PER_TOKEN = 4;
|
|
1
8
|
/**
|
|
2
9
|
* Rough token count. Characters over four, because there is no tokenizer here and there is not
|
|
3
10
|
* going to be one: a server that will not say how big its window is will not lend us its
|
|
@@ -10,9 +17,9 @@
|
|
|
10
17
|
*
|
|
11
18
|
* Its own module because it is the one number several of these agree on, and the module that
|
|
12
19
|
* owns it should not be one that also does something. It was extracted from `side-task` to
|
|
13
|
-
* break a cycle with `retry`;
|
|
14
|
-
* sizing its own prompt
|
|
20
|
+
* break a cycle with `retry`; neither reads it now, but `hooks` does to cap a context block, as
|
|
21
|
+
* does any consumer sizing its own prompt, and a leaf with no imports is the right home for it.
|
|
15
22
|
*
|
|
16
23
|
* @param text Prose or serialised JSON — both counted the same way, which is why JSON reads low.
|
|
17
24
|
*/
|
|
18
|
-
export const estimateTokens = (text) => Math.ceil(text.length /
|
|
25
|
+
export const estimateTokens = (text) => Math.ceil(text.length / CHARS_PER_TOKEN);
|
package/dist/tool-calls.d.ts
CHANGED
|
@@ -26,6 +26,15 @@ export declare class ToolArgumentsError extends Error {
|
|
|
26
26
|
*/
|
|
27
27
|
constructor(kind: "truncated" | "malformed", message: string);
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* JSON as it was written, then repaired, then undefined. A string holding JSON is opened once.
|
|
31
|
+
*
|
|
32
|
+
* Read as written first, so the repair can only ever add to what parses: nothing valid is
|
|
33
|
+
* reinterpreted on its way through.
|
|
34
|
+
*
|
|
35
|
+
* @param text What a model wrote where JSON was asked for, with no prose around it.
|
|
36
|
+
*/
|
|
37
|
+
export declare function looseJson(text: string): unknown;
|
|
29
38
|
/**
|
|
30
39
|
* A tool call's arguments as the object the tool is handed. Empty is no arguments.
|
|
31
40
|
*
|