toolnexus 0.26.0 → 0.28.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 +6 -2
- package/dist/acp.d.ts +16 -2
- package/dist/acp.js +74 -21
- package/dist/agents/home.d.ts +85 -4
- package/dist/agents/home.js +347 -52
- package/dist/classifier.d.ts +21 -3
- package/dist/classifier.js +175 -9
- package/dist/client.d.ts +109 -0
- package/dist/client.js +380 -75
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +29 -7
- package/dist/mcp2026/source.d.ts +5 -0
- package/dist/mcp2026/source.js +11 -2
- package/dist/mcp2026/stdio.d.ts +43 -0
- package/dist/mcp2026/stdio.js +144 -20
- package/dist/mcphandler.js +2 -4
- package/dist/mcpserve.js +2 -1
- package/dist/nodefetch.d.ts +1 -0
- package/dist/nodefetch.js +156 -0
- package/dist/serve.js +5 -1
- package/dist/skill.d.ts +14 -0
- package/dist/skill.js +33 -2
- package/dist/transcript_search.d.ts +27 -0
- package/dist/transcript_search.js +165 -0
- package/package.json +2 -2
- package/src/acp.ts +101 -22
- package/src/agents/home.ts +381 -53
- package/src/classifier.ts +168 -12
- package/src/client.ts +368 -57
- package/src/index.ts +1 -0
- package/src/mcp.ts +36 -7
- package/src/mcp2026/source.ts +14 -4
- package/src/mcp2026/stdio.ts +148 -23
- package/src/mcphandler.ts +2 -3
- package/src/mcpserve.ts +2 -1
- package/src/nodefetch.ts +143 -0
- package/src/serve.ts +6 -2
- package/src/skill.ts +51 -2
- package/src/transcript_search.ts +172 -0
package/dist/classifier.js
CHANGED
|
@@ -68,14 +68,47 @@ export const CLASSIFIER_BACKENDS = Object.freeze({
|
|
|
68
68
|
imagesHeader: true,
|
|
69
69
|
supports: () => ({ images: false }),
|
|
70
70
|
},
|
|
71
|
+
openai: {
|
|
72
|
+
baseUrl: "https://api.openai.com/v1",
|
|
73
|
+
model: "gpt-6-luna",
|
|
74
|
+
apiKeyEnv: "OPENAI_API_KEY",
|
|
75
|
+
wire: "decisions",
|
|
76
|
+
imagesHeader: false,
|
|
77
|
+
supports: () => ({ images: true }),
|
|
78
|
+
},
|
|
71
79
|
custom: {
|
|
72
80
|
baseUrl: "",
|
|
73
81
|
model: DEFAULT_CLASSIFIER_MODEL,
|
|
74
|
-
apiKeyEnv:
|
|
82
|
+
apiKeyEnv: "",
|
|
75
83
|
wire: "systemone",
|
|
76
84
|
imagesHeader: true,
|
|
77
85
|
},
|
|
78
86
|
});
|
|
87
|
+
/**
|
|
88
|
+
* The env var whose key may be sent to `baseUrl` (already `${NAME}`-expanded): the backend's own
|
|
89
|
+
* variable, and only when `baseUrl` is that backend's own host over https (OpenRouter also its
|
|
90
|
+
* subdomains). `""` when none may — `custom` has no preset key at all. A key is never sent to a
|
|
91
|
+
* host the caller did not name (#155, SPEC §8B).
|
|
92
|
+
*/
|
|
93
|
+
export function classifierKeyEnv(backend, baseUrl) {
|
|
94
|
+
const p = Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, backend) ? CLASSIFIER_BACKENDS[backend] : undefined;
|
|
95
|
+
if (!p || !p.apiKeyEnv || !p.baseUrl)
|
|
96
|
+
return "";
|
|
97
|
+
const hostOf = (u) => {
|
|
98
|
+
try {
|
|
99
|
+
const url = new URL(u);
|
|
100
|
+
return url.protocol === "https:" ? url.hostname.toLowerCase() : "";
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return "";
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const h = hostOf(baseUrl);
|
|
107
|
+
const own = hostOf(p.baseUrl);
|
|
108
|
+
if (!h || !own)
|
|
109
|
+
return "";
|
|
110
|
+
return h === own || (backend === "openrouter" && h.endsWith(".openrouter.ai")) ? p.apiKeyEnv : "";
|
|
111
|
+
}
|
|
79
112
|
/**
|
|
80
113
|
* Raised BEFORE any network call when the backend table says the backend cannot take what the
|
|
81
114
|
* request carries. Message, exact: `classifier: <backend> (<model>) does not support <feature>`.
|
|
@@ -380,6 +413,9 @@ export class Classifier {
|
|
|
380
413
|
baseUrl;
|
|
381
414
|
model;
|
|
382
415
|
apiKeyEnv;
|
|
416
|
+
/** The caller NAMED apiKeyEnv: sent wherever baseUrl points. Otherwise the preset's own key,
|
|
417
|
+
* and only to its own host (classifierKeyEnv, #155). */
|
|
418
|
+
explicitKeyEnv;
|
|
383
419
|
timeoutMs;
|
|
384
420
|
backend;
|
|
385
421
|
preset;
|
|
@@ -393,7 +429,7 @@ export class Classifier {
|
|
|
393
429
|
this.style = opts.style ?? "systemone";
|
|
394
430
|
this.backend = opts.backend ?? "typesafe";
|
|
395
431
|
if (!Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, this.backend)) {
|
|
396
|
-
throw new Error(`classifier: unknown backend "${this.backend}" (want "typesafe", "openrouter", "cloudflare", "vercel" or "custom")`);
|
|
432
|
+
throw new Error(`classifier: unknown backend "${this.backend}" (want "typesafe", "openrouter", "cloudflare", "vercel", "openai" or "custom")`);
|
|
397
433
|
}
|
|
398
434
|
const preset = CLASSIFIER_BACKENDS[this.backend];
|
|
399
435
|
this.preset = preset;
|
|
@@ -403,6 +439,7 @@ export class Classifier {
|
|
|
403
439
|
this.baseUrl = opts.baseUrl || preset.baseUrl;
|
|
404
440
|
this.model = opts.model ?? preset.model;
|
|
405
441
|
this.apiKeyEnv = opts.apiKeyEnv ?? preset.apiKeyEnv;
|
|
442
|
+
this.explicitKeyEnv = opts.apiKeyEnv !== undefined;
|
|
406
443
|
const mismatch = backendMismatch(this.baseUrl, this.model);
|
|
407
444
|
if (mismatch)
|
|
408
445
|
throw new Error(mismatch);
|
|
@@ -617,7 +654,10 @@ export class Classifier {
|
|
|
617
654
|
for (const [key, q] of Object.entries(questions))
|
|
618
655
|
qs[key] = questionWire(q);
|
|
619
656
|
let body;
|
|
620
|
-
if (this.preset.wire === "
|
|
657
|
+
if (this.preset.wire === "decisions") {
|
|
658
|
+
body = { model: this.model, input: decisionsInput(p), questions: decisionsQuestions(this.backend, this.model, questions) };
|
|
659
|
+
}
|
|
660
|
+
else if (this.preset.wire === "cloudflare") {
|
|
621
661
|
const input = { questions: qs, state: p.state };
|
|
622
662
|
if (p.images.length > 0)
|
|
623
663
|
input.images = p.images.map((im) => `data:image/${im.format};base64,${im.data}`);
|
|
@@ -694,6 +734,10 @@ export class Classifier {
|
|
|
694
734
|
parsed = unwrapCloudflare(parsed);
|
|
695
735
|
text = canonicalJson(parsed);
|
|
696
736
|
}
|
|
737
|
+
else if (this.preset.wire === "decisions") {
|
|
738
|
+
text = decisionsReply(parsed, questions);
|
|
739
|
+
parsed = JSON.parse(text);
|
|
740
|
+
}
|
|
697
741
|
const d = decodeDecision(parsed, this.model);
|
|
698
742
|
const verr = validateDecision(questions, d);
|
|
699
743
|
if (validate && verr)
|
|
@@ -718,7 +762,9 @@ export class Classifier {
|
|
|
718
762
|
// `endpoint` is what errors name: the CONFIGURED base, ${ENV} unexpanded, so an account id in
|
|
719
763
|
// a templated URL never reaches an error. `url` is what is dialled, expanded at call time.
|
|
720
764
|
let endpoint = this.baseUrl.replace(/\/+$/, "");
|
|
721
|
-
if (this.preset.wire
|
|
765
|
+
if (this.preset.wire === "decisions")
|
|
766
|
+
endpoint += "/decisions";
|
|
767
|
+
else if (this.preset.wire !== "cloudflare")
|
|
722
768
|
endpoint += "/systemone";
|
|
723
769
|
const url = endpoint.includes("${") ? expandEnv(endpoint) : endpoint;
|
|
724
770
|
const retries = this.opts.retries ?? 2;
|
|
@@ -745,7 +791,7 @@ export class Classifier {
|
|
|
745
791
|
try {
|
|
746
792
|
res = await doFetch(url, {
|
|
747
793
|
method: "POST",
|
|
748
|
-
headers: this.requestHeaders(extraHeaders),
|
|
794
|
+
headers: this.requestHeaders(extraHeaders, url),
|
|
749
795
|
body: payload,
|
|
750
796
|
signal: timer.signal,
|
|
751
797
|
});
|
|
@@ -781,9 +827,10 @@ export class Classifier {
|
|
|
781
827
|
}
|
|
782
828
|
}
|
|
783
829
|
/** Credential and `${ENV_VAR}` headers resolve AT CALL TIME. Never logged, never returned. */
|
|
784
|
-
requestHeaders(extra
|
|
830
|
+
requestHeaders(extra, url) {
|
|
785
831
|
const headers = { "Content-Type": "application/json" };
|
|
786
|
-
const
|
|
832
|
+
const keyEnv = this.explicitKeyEnv ? this.apiKeyEnv : classifierKeyEnv(this.backend, url);
|
|
833
|
+
const key = keyEnv ? process.env[keyEnv] : undefined;
|
|
787
834
|
if (key)
|
|
788
835
|
headers["Authorization"] = `Bearer ${key}`;
|
|
789
836
|
// The image opt-in header goes BEFORE host headers, which win.
|
|
@@ -1057,10 +1104,29 @@ function imageSize(format, b) {
|
|
|
1057
1104
|
}
|
|
1058
1105
|
return undefined;
|
|
1059
1106
|
}
|
|
1060
|
-
/**
|
|
1107
|
+
/**
|
|
1108
|
+
* `: <code> <message>` from errors[0] of a failed envelope (SPEC §8B), through the same redaction
|
|
1109
|
+
* and cap as cause(). Cloudflare reports a missing balance, a bad model id and a malformed
|
|
1110
|
+
* request alike as HTTP 200 + success:false, so this is the only thing that tells them apart.
|
|
1111
|
+
* `""` when there is nothing to show.
|
|
1112
|
+
*/
|
|
1113
|
+
function cloudflareCause(env) {
|
|
1114
|
+
const errs = env !== null && typeof env === "object" ? env.errors : undefined;
|
|
1115
|
+
const e0 = Array.isArray(errs) ? errs[0] : undefined;
|
|
1116
|
+
if (e0 === null || typeof e0 !== "object")
|
|
1117
|
+
return "";
|
|
1118
|
+
const parts = [];
|
|
1119
|
+
if ((typeof e0.code === "number" || typeof e0.code === "string") && String(e0.code) !== "")
|
|
1120
|
+
parts.push(String(e0.code));
|
|
1121
|
+
if (typeof e0.message === "string" && e0.message !== "")
|
|
1122
|
+
parts.push(e0.message);
|
|
1123
|
+
const s = capErrorBody(redactErrorBody(parts.join(" ")));
|
|
1124
|
+
return s ? ": " + s : "";
|
|
1125
|
+
}
|
|
1126
|
+
/** The System One reply inside a Cloudflare `/ai/run` envelope. Only errors[0] is ever echoed. */
|
|
1061
1127
|
function unwrapCloudflare(env) {
|
|
1062
1128
|
if (env === null || typeof env !== "object" || Array.isArray(env) || env.success !== true) {
|
|
1063
|
-
throw new Error("classifier: cloudflare: envelope not successful");
|
|
1129
|
+
throw new Error("classifier: cloudflare: envelope not successful" + cloudflareCause(env));
|
|
1064
1130
|
}
|
|
1065
1131
|
let result = env.result;
|
|
1066
1132
|
if (result !== null && typeof result === "object" && !Array.isArray(result) && typeof result.state === "string") {
|
|
@@ -1228,3 +1294,103 @@ export function chunkQuestions(questions, size) {
|
|
|
1228
1294
|
}
|
|
1229
1295
|
return out;
|
|
1230
1296
|
}
|
|
1297
|
+
// ---------------------------------------------------------------- the Decisions wire (#157)
|
|
1298
|
+
/** The Decisions API's per-request question cap. */
|
|
1299
|
+
export const MAX_DECISIONS_QUESTIONS = 400;
|
|
1300
|
+
/**
|
|
1301
|
+
* Maps the question set to the Decisions API list, in ASCII key order. Caller keys are NOT sent
|
|
1302
|
+
* (no `name`): §8B keys are addressing, not content, and answers come back in request order.
|
|
1303
|
+
* Every check fires before any request.
|
|
1304
|
+
*/
|
|
1305
|
+
function decisionsQuestions(backend, model, questions) {
|
|
1306
|
+
const keys = Object.keys(questions).sort();
|
|
1307
|
+
if (keys.length > MAX_DECISIONS_QUESTIONS) {
|
|
1308
|
+
throw new Error(`classifier: the decisions wire takes at most ${MAX_DECISIONS_QUESTIONS} questions, got ${keys.length}`);
|
|
1309
|
+
}
|
|
1310
|
+
return keys.map((k) => {
|
|
1311
|
+
const q = questions[k];
|
|
1312
|
+
if (isRawQuestion(q))
|
|
1313
|
+
throw new ClassifierUnsupported(backend, model, `raw question type "${q.type}"`);
|
|
1314
|
+
const m = q;
|
|
1315
|
+
switch (m.type) {
|
|
1316
|
+
case "noul": {
|
|
1317
|
+
let instructions = m.instructions;
|
|
1318
|
+
if (m.criteria !== undefined)
|
|
1319
|
+
instructions += `\n\nTrue means: ${m.criteria.true}\nFalse means: ${m.criteria.false}`;
|
|
1320
|
+
return { type: "predicate", instructions };
|
|
1321
|
+
}
|
|
1322
|
+
case "choice": {
|
|
1323
|
+
const ids = Object.keys(m.criteria ?? {}).sort();
|
|
1324
|
+
if (ids.length < 2) {
|
|
1325
|
+
throw new Error(`classifier: question "${k}": the decisions wire needs 2..${MAX_CHOICE_OPTIONS} choice options, got ${ids.length}`);
|
|
1326
|
+
}
|
|
1327
|
+
const choices = ids.map((id) => (m.criteria[id] === "" ? { value: id } : { value: id, description: m.criteria[id] }));
|
|
1328
|
+
return { type: "choice", instructions: m.instructions, choices };
|
|
1329
|
+
}
|
|
1330
|
+
case "score":
|
|
1331
|
+
return { type: "score", instructions: m.instructions, levels: m.criteria.map((label) => ({ label })) };
|
|
1332
|
+
}
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
/** The state as sent: a string verbatim, else canonical JSON text; with images, one user message. */
|
|
1336
|
+
function decisionsInput(p) {
|
|
1337
|
+
const text = typeof p.state === "string" ? p.state : canonicalJson(p.state);
|
|
1338
|
+
if (p.images.length === 0)
|
|
1339
|
+
return text;
|
|
1340
|
+
const content = [{ type: "input_text", text }];
|
|
1341
|
+
for (const im of p.images)
|
|
1342
|
+
content.push({ type: "input_image", image_url: `data:image/${im.format};base64,${im.data}` });
|
|
1343
|
+
return [{ role: "user", content }];
|
|
1344
|
+
}
|
|
1345
|
+
/** A choice value or level index as the §8B key: string as written, boolean "true"/"false", number shortest. */
|
|
1346
|
+
function decisionsValue(v) {
|
|
1347
|
+
return typeof v === "string" ? v : String(v);
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Reads a Decisions API reply back into a System One-shaped body (canonical JSON), so decode,
|
|
1351
|
+
* validation and the cache are the one path. `calibrated` is false. Exported for conformance tests.
|
|
1352
|
+
*/
|
|
1353
|
+
export function decisionsReply(reply, questions) {
|
|
1354
|
+
const r = reply;
|
|
1355
|
+
if (r === null || typeof r !== "object" || Array.isArray(r) || !Array.isArray(r.answers)) {
|
|
1356
|
+
throw new Error("classifier: decisions: malformed reply");
|
|
1357
|
+
}
|
|
1358
|
+
const keys = Object.keys(questions).sort();
|
|
1359
|
+
if (r.answers.length !== keys.length) {
|
|
1360
|
+
throw new Error(`classifier: decisions: ${r.answers.length} answers for ${keys.length} questions`);
|
|
1361
|
+
}
|
|
1362
|
+
const num = (v) => (typeof v === "number" ? v : 0);
|
|
1363
|
+
const want = { noul: "predicate", choice: "choice", score: "score" };
|
|
1364
|
+
const answers = {};
|
|
1365
|
+
keys.forEach((k, i) => {
|
|
1366
|
+
const a = r.answers[i];
|
|
1367
|
+
if (a === null || typeof a !== "object" || Array.isArray(a))
|
|
1368
|
+
throw new Error("classifier: decisions: malformed reply");
|
|
1369
|
+
if (a.type === "refusal")
|
|
1370
|
+
throw new Error(`classifier: decisions: question "${k}" was refused`);
|
|
1371
|
+
const q = questions[k];
|
|
1372
|
+
const w = want[q.type] ?? "";
|
|
1373
|
+
if (a.type !== w)
|
|
1374
|
+
throw new Error(`classifier: decisions: answer "${k}" is a ${a.type} answer, not ${w}`);
|
|
1375
|
+
const probs = {};
|
|
1376
|
+
for (const p of Array.isArray(a.probabilities) ? a.probabilities : [])
|
|
1377
|
+
probs[decisionsValue(p?.value)] = num(p?.probability);
|
|
1378
|
+
if (q.type === "noul")
|
|
1379
|
+
answers[k] = { type: "noul", noul: num(a.probability) };
|
|
1380
|
+
else if (q.type === "choice") {
|
|
1381
|
+
answers[k] = { type: "choice", choice: decisionsValue(a.choice), probabilities: probs, confidence: num(a.confidence) };
|
|
1382
|
+
}
|
|
1383
|
+
else {
|
|
1384
|
+
const legend = {};
|
|
1385
|
+
q.criteria.forEach((l, j) => (legend[String(j)] = l));
|
|
1386
|
+
answers[k] = { type: "score", score: num(a.score), legend, probabilities: probs, confidence: num(a.confidence) };
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
const u = r.usage ?? {};
|
|
1390
|
+
return canonicalJson({
|
|
1391
|
+
answers,
|
|
1392
|
+
calibrated: false,
|
|
1393
|
+
model: typeof r.model === "string" ? r.model : "",
|
|
1394
|
+
usage: { input_tokens: num(u.input_tokens), output_tokens: num(u.output_tokens) },
|
|
1395
|
+
});
|
|
1396
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -26,11 +26,20 @@ export interface ClientOptions {
|
|
|
26
26
|
headers?: Record<string, string>;
|
|
27
27
|
systemPrompt?: string;
|
|
28
28
|
maxTurns?: number;
|
|
29
|
+
/** SPEC §8 "Stop-reason recovery". Default false. On, an empty answer is nudged once, a
|
|
30
|
+
* `length`/`max_tokens` cut is continued (up to 3 times, text carried) and malformed tool-call
|
|
31
|
+
* arguments get one corrective turn — each an ordinary turn, only while a turn remains. */
|
|
32
|
+
stopRecovery?: boolean;
|
|
29
33
|
/** Cap on concurrency-safe tool calls of one response running at once (SPEC §8 "Tool
|
|
30
34
|
* scheduling"). Absent ⇒ env `TOOLNEXUS_MAX_TOOL_CONCURRENCY` when a positive integer, else 10.
|
|
31
35
|
* Unsafe calls (anything not read-only) always run alone. */
|
|
32
36
|
maxToolConcurrency?: number;
|
|
33
37
|
hooks?: Hooks;
|
|
38
|
+
/** SPEC §7F.1 reactive compaction. Called (with the `beforeLLM` signature) when the provider
|
|
39
|
+
* rejects a call as a context overflow (`isContextOverflow`); strictly fewer `messages` replace
|
|
40
|
+
* the transcript and the same turn is re-sent. Breaker: 3 rapid compactions ⇒ `ContextOverflowError`.
|
|
41
|
+
* Absent ⇒ the overflow surfaces as `LlmHttpError`, unchanged. */
|
|
42
|
+
compactOnOverflow?: Hooks["beforeLLM"];
|
|
34
43
|
/** Retries on transient LLM errors (`429`/`500`/`502`/`503`/`504`/`529` + network). Default 2.
|
|
35
44
|
* Widen the status set with `retryableStatuses`. */
|
|
36
45
|
retries?: number;
|
|
@@ -97,6 +106,11 @@ export interface ClientOptions {
|
|
|
97
106
|
* today. A "retry" is always bounded by `retries`; the classifier cannot loop unbounded.
|
|
98
107
|
* There is no "suspend" tier — suspension (§10) stays a user-action pause, not an error path. */
|
|
99
108
|
onError?: (info: ErrorInfo) => ErrorTier;
|
|
109
|
+
/** §8 Resilience. Ordered fallback model ids tried, with a fresh retry budget each, when the
|
|
110
|
+
* current model's retry budget ends on an overload (`503`/`529`). Chain = [turnModel, ...these];
|
|
111
|
+
* empty/duplicate entries skipped. `TOOLNEXUS_DISABLE_MODEL_FALLBACK=1|true` disables it.
|
|
112
|
+
* `stream` emits `{ type: "fallback", from, to }` before switching. Omit ⇒ byte-identical. */
|
|
113
|
+
fallbackModels?: readonly string[];
|
|
100
114
|
/** §1B. Reject a content part carrying more than this many **decoded** bytes (never the
|
|
101
115
|
* +33% base64 string). Unset ⇒ no limit, as today. */
|
|
102
116
|
maxPartBytes?: number;
|
|
@@ -159,6 +173,26 @@ export declare class LlmHttpError extends Error {
|
|
|
159
173
|
}
|
|
160
174
|
/** Build the typed error from a non-2xx provider response (reads the body once). */
|
|
161
175
|
export declare function llmHttpError(res: Response): Promise<LlmHttpError>;
|
|
176
|
+
/** SPEC §7F.1: true iff `status` is 400/413 AND the (redacted) body names a context overflow. */
|
|
177
|
+
export declare function isContextOverflow(status: number, body: string): boolean;
|
|
178
|
+
/** SPEC §7F.1: reactive compaction could not make the prompt fit (breaker tripped, or the
|
|
179
|
+
* `compactOnOverflow` hook made no progress). A failure, never a §10 pending. */
|
|
180
|
+
export declare class ContextOverflowError extends Error {
|
|
181
|
+
/** Reactive compactions in the current rapid chain (3 when the breaker tripped). */
|
|
182
|
+
readonly compactions: number;
|
|
183
|
+
/** Status of the last provider error. */
|
|
184
|
+
readonly status: number;
|
|
185
|
+
/** Redacted body of the last provider error (same redaction as `LlmHttpError`). */
|
|
186
|
+
readonly body: string;
|
|
187
|
+
readonly name = "ContextOverflowError";
|
|
188
|
+
constructor(
|
|
189
|
+
/** Reactive compactions in the current rapid chain (3 when the breaker tripped). */
|
|
190
|
+
compactions: number,
|
|
191
|
+
/** Status of the last provider error. */
|
|
192
|
+
status: number,
|
|
193
|
+
/** Redacted body of the last provider error (same redaction as `LlmHttpError`). */
|
|
194
|
+
body: string);
|
|
195
|
+
}
|
|
162
196
|
/** §8 Resilience. What to do with a failed LLM call. */
|
|
163
197
|
export type ErrorTier = "retry" | "fail";
|
|
164
198
|
/** §8 Resilience. Context passed to `onError` for each failed LLM attempt. */
|
|
@@ -301,6 +335,21 @@ export interface Hooks {
|
|
|
301
335
|
} | Promise<void | {
|
|
302
336
|
result?: ToolResult;
|
|
303
337
|
}>;
|
|
338
|
+
/** At each round boundary (SPEC §8, #192): after a round's tool results (final=false), or when the
|
|
339
|
+
* model stopped with a turn remaining (final=true, text = its answer). Each `inject` string is
|
|
340
|
+
* appended as a user message; a non-empty inject on a final round CONTINUES the loop. */
|
|
341
|
+
afterRound?(ev: AfterRoundEvent): void | RoundInjection | Promise<void | RoundInjection>;
|
|
342
|
+
}
|
|
343
|
+
/** The `afterRound` hook event. */
|
|
344
|
+
export interface AfterRoundEvent {
|
|
345
|
+
turn: number;
|
|
346
|
+
final: boolean;
|
|
347
|
+
text: string;
|
|
348
|
+
messages: any[];
|
|
349
|
+
}
|
|
350
|
+
/** What an `afterRound` hook may return. */
|
|
351
|
+
export interface RoundInjection {
|
|
352
|
+
inject?: string[];
|
|
304
353
|
}
|
|
305
354
|
/** A tool call the model made, plus its result + metadata. */
|
|
306
355
|
export interface ToolCallRecord {
|
|
@@ -379,6 +428,15 @@ export type StreamEvent = {
|
|
|
379
428
|
name: string;
|
|
380
429
|
args: Record<string, unknown>;
|
|
381
430
|
via?: "call_tool" | "acp";
|
|
431
|
+
}
|
|
432
|
+
/** An ACP agent's `tool_call_update` carrying `rawInput`: the call's arguments, known after
|
|
433
|
+
* the initial `tool_call` (#188). Reported only; never executed by the loop. */
|
|
434
|
+
| {
|
|
435
|
+
type: "tool_call_args";
|
|
436
|
+
id: string;
|
|
437
|
+
name: string;
|
|
438
|
+
args: Record<string, unknown>;
|
|
439
|
+
via: "acp";
|
|
382
440
|
} | {
|
|
383
441
|
type: "tool_result";
|
|
384
442
|
id: string;
|
|
@@ -390,9 +448,26 @@ export type StreamEvent = {
|
|
|
390
448
|
} | {
|
|
391
449
|
type: "usage";
|
|
392
450
|
usage: Usage;
|
|
451
|
+
}
|
|
452
|
+
/** Only with `fallbackModels`: switching from model `from` to `to` after overload (§8). */
|
|
453
|
+
| {
|
|
454
|
+
type: "fallback";
|
|
455
|
+
from: string;
|
|
456
|
+
to: string;
|
|
393
457
|
} | {
|
|
394
458
|
type: "pending";
|
|
395
459
|
request: Request;
|
|
460
|
+
}
|
|
461
|
+
/** Emitted just before each LLM retry wait (streaming only). `attempt` is 1-based (the
|
|
462
|
+
* failed zero-based attempt + 1), `max` the configured `retries`, `delayMs` the exact wait,
|
|
463
|
+
* `status` the HTTP status (absent on a network error), `reason` "HTTP <status>" | "network". */
|
|
464
|
+
| {
|
|
465
|
+
type: "retry";
|
|
466
|
+
attempt: number;
|
|
467
|
+
max: number;
|
|
468
|
+
delayMs: number;
|
|
469
|
+
status?: number;
|
|
470
|
+
reason: string;
|
|
396
471
|
} | {
|
|
397
472
|
type: "done";
|
|
398
473
|
result: RunResult;
|
|
@@ -521,6 +596,20 @@ export declare class Client {
|
|
|
521
596
|
* plus network errors and anything `retryableStatuses` added, honoring Retry-After; aborts via signal.
|
|
522
597
|
* The retry-vs-fail decision is the host's `onError` (default: retryable-within-budget ⇒ retry). */
|
|
523
598
|
private llmFetch;
|
|
599
|
+
/** The retry loop behind `llmFetch`, as a generator: yields a `retry` StreamEvent just before
|
|
600
|
+
* each retry wait (its `delayMs` is the exact wait slept) and returns the final Response.
|
|
601
|
+
* The streaming generators consume it with `yield*`. */
|
|
602
|
+
private llmFetchEvents;
|
|
603
|
+
/** §8 fallbackModels: the per-call model chain — [turnModel, ...fallbackModels], empty and
|
|
604
|
+
* duplicate entries skipped; just [turnModel] when the option is absent or the kill switch is set. */
|
|
605
|
+
private modelChain;
|
|
606
|
+
/** llmFetch over the model chain: each model spends the full retry budget; only a budget that
|
|
607
|
+
* ends on 503/529 (not an onError "fail") advances to the next model. Returns the response and
|
|
608
|
+
* the model that produced it. `build` rebuilds the same request for a model. */
|
|
609
|
+
private llmFetchChain;
|
|
610
|
+
/** The fallback chain as a generator for the streaming loops: the #189 `retry` events of each
|
|
611
|
+
* model's attempts and a `fallback` event at each switch reach the stream as they happen. */
|
|
612
|
+
private llmFetchChainEvents;
|
|
524
613
|
/** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
|
|
525
614
|
private runTool;
|
|
526
615
|
private runToolDirect;
|
|
@@ -550,11 +639,31 @@ export declare class Client {
|
|
|
550
639
|
private emitTool;
|
|
551
640
|
/** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
|
|
552
641
|
private llmCallJson;
|
|
642
|
+
/**
|
|
643
|
+
* SPEC §7F.1: one LLM call with reactive compaction. `call(messages)` makes the request and
|
|
644
|
+
* throws on a non-2xx (as `LlmHttpError`). On a context overflow with `compactOnOverflow` set,
|
|
645
|
+
* the hook compacts and the SAME turn is re-sent (no beforeLLM, no turn consumed), guarded by
|
|
646
|
+
* the rapid-refill breaker. Returns the call's value and the (possibly compacted) transcript.
|
|
647
|
+
*/
|
|
648
|
+
private callWithOverflow;
|
|
649
|
+
/** `callWithOverflow` for the streaming loops: `call` is a generator so the #189 `retry` events
|
|
650
|
+
* of every attempt (including one after a compaction) still reach the stream before each wait. */
|
|
651
|
+
private callWithOverflowEvents;
|
|
553
652
|
private runOpenAI;
|
|
554
653
|
private runAnthropic;
|
|
555
654
|
private streamOpenAI;
|
|
556
655
|
private streamAnthropic;
|
|
656
|
+
/**
|
|
657
|
+
* §8 stop-reason recovery: classify the response just appended to `messages` and, when a rule
|
|
658
|
+
* fires, remove (or keep) that assistant message, append the pinned user message, and return
|
|
659
|
+
* true — the caller then takes another turn. Never on the last permitted turn.
|
|
660
|
+
*/
|
|
661
|
+
private recover;
|
|
662
|
+
private newRecovery;
|
|
557
663
|
private runOpenAIWithSignal;
|
|
664
|
+
/** §8 afterRound: fire the round-boundary hook and append its injections as user messages.
|
|
665
|
+
* Returns true when something was appended. A final round fires only when a turn remains. */
|
|
666
|
+
private roundEnd;
|
|
558
667
|
private result;
|
|
559
668
|
/** §10: a run halted because a tool suspended and no `waitFor` was configured. */
|
|
560
669
|
private pendingRun;
|