teapot-coding-agent 0.29.4 → 0.29.6
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/dist/agent/agent.js +19 -3
- package/dist/log/events.js +42 -2
- package/dist/master.js +154 -25
- package/dist/model-meta.js +40 -1
- package/package.json +1 -1
- package/public/assets/{index-9fqlvpxR.js → index-WARWw8ga.js} +4 -4
- package/public/assets/{index-9fqlvpxR.js.map → index-WARWw8ga.js.map} +1 -1
- package/public/assets/{shiki-CDs87bRU.js → shiki-BPGMQVPH.js} +2 -2
- package/public/assets/{shiki-CDs87bRU.js.map → shiki-BPGMQVPH.js.map} +1 -1
- package/public/index.html +1 -1
package/dist/agent/agent.js
CHANGED
|
@@ -628,9 +628,25 @@ export class Agent {
|
|
|
628
628
|
this.goal = stored ?? { text: "", status: "active", updatedAt: new Date().toISOString() };
|
|
629
629
|
// operator-maintained task list lives beside goal.md
|
|
630
630
|
this.todo = await fs.readFile(this.todoFile, "utf8").catch(() => "");
|
|
631
|
-
|
|
632
|
-
//
|
|
633
|
-
//
|
|
631
|
+
// #143: NO skill scan here. It was the single largest boot cost — 493 agents
|
|
632
|
+
// each walking their roots (readdir + stat, recursively) measured 2120 ms of
|
|
633
|
+
// a 4.7 s boot. Every one of those walks was redundant:
|
|
634
|
+
//
|
|
635
|
+
// - the turn loop calls `refreshSkills()` at the TOP of every turn, before
|
|
636
|
+
// the system prompt (which embeds the catalogue) is built, so the scan is
|
|
637
|
+
// always current before any LLM request
|
|
638
|
+
// - the `list_skills` tool calls it itself before reading the cache
|
|
639
|
+
// - `/api/agents/:id/skills` (what the panel uses) does its own fresh
|
|
640
|
+
// `currentSkills()` walk and never reads this cache
|
|
641
|
+
//
|
|
642
|
+
// Caching the walk was the other option and was WORSE: the fingerprint exists
|
|
643
|
+
// to notice a NESTED skill's add/edit, which does not change its root's mtime,
|
|
644
|
+
// so any TTL/mtime key reintroduces the staleness the walk was written to fix
|
|
645
|
+
// (the skills-fingerprint tests pin exactly that). Skipping boot's copy costs
|
|
646
|
+
// no freshness at all.
|
|
647
|
+
//
|
|
648
|
+
// The conversation is likewise not restored here: boot cost stays O(agents),
|
|
649
|
+
// not O(history). Both are rebuilt lazily by ensureReady()/the turn loop.
|
|
634
650
|
if (this.opts.restoreSession) {
|
|
635
651
|
this.status = "stopped";
|
|
636
652
|
this.statusReason = "session not loaded — select it or send a prompt";
|
package/dist/log/events.js
CHANGED
|
@@ -57,11 +57,51 @@ export class EventLog {
|
|
|
57
57
|
this.filePath = filePath;
|
|
58
58
|
this.agentId = agentId;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Bytes read from the END of the log to rebuild the in-memory head.
|
|
62
|
+
*
|
|
63
|
+
* `load()` used to `readFile()` the ENTIRE log and `JSON.parse` every event,
|
|
64
|
+
* only to learn two things: the highest `seq`, and the last event id per
|
|
65
|
+
* branch. MEASURED on a real 813-session data dir (369 MB of logs): that read
|
|
66
|
+
* was the single largest boot cost — 1603 ms of `load()` against 493 agents,
|
|
67
|
+
* plus the GC pressure of holding one whole log as a string and its `split`
|
|
68
|
+
* array (~50 MB of temporary garbage for a 23 MB log).
|
|
69
|
+
*
|
|
70
|
+
* Both facts live at the END of an append-only file:
|
|
71
|
+
* - `seq` is assigned monotonically, so the last complete line holds the max
|
|
72
|
+
* - `lastEventId()` is only ever called for the CURRENT branch (fork/spawn),
|
|
73
|
+
* which is by definition the most recently written one
|
|
74
|
+
*
|
|
75
|
+
* So the tail is both sufficient and exact. A branch that has been dormant for
|
|
76
|
+
* more than WINDOW bytes loses its in-memory head; that only affects
|
|
77
|
+
* `parent:` on the next append to that stale branch, and the paths that matter
|
|
78
|
+
* (`fork`, child spawn) re-seed explicitly through `seedBranch()`.
|
|
79
|
+
*/
|
|
80
|
+
static LOAD_TAIL_BYTES = 512 * 1024;
|
|
60
81
|
async load() {
|
|
61
82
|
mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
62
83
|
try {
|
|
63
|
-
const { readFile } = await import("node:fs/promises");
|
|
64
|
-
const
|
|
84
|
+
const { readFile, stat, open } = await import("node:fs/promises");
|
|
85
|
+
const st = await stat(this.filePath);
|
|
86
|
+
let text;
|
|
87
|
+
if (st.size <= EventLog.LOAD_TAIL_BYTES) {
|
|
88
|
+
text = await readFile(this.filePath, "utf8"); // small log: read it all
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const start = st.size - EventLog.LOAD_TAIL_BYTES;
|
|
92
|
+
const fh = await open(this.filePath, "r");
|
|
93
|
+
try {
|
|
94
|
+
const buf = Buffer.alloc(EventLog.LOAD_TAIL_BYTES);
|
|
95
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, start);
|
|
96
|
+
text = buf.subarray(0, bytesRead).toString("utf8");
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
await fh.close();
|
|
100
|
+
}
|
|
101
|
+
// the window starts mid-line: drop the partial first line
|
|
102
|
+
const nl = text.indexOf("\n");
|
|
103
|
+
text = nl === -1 ? "" : text.slice(nl + 1);
|
|
104
|
+
}
|
|
65
105
|
for (const line of text.split("\n")) {
|
|
66
106
|
if (!line.trim())
|
|
67
107
|
continue;
|
package/dist/master.js
CHANGED
|
@@ -13,9 +13,19 @@ import { spawn } from "node:child_process";
|
|
|
13
13
|
import { Agent } from "./agent/agent.js";
|
|
14
14
|
import { parseSchedule, matches, nextFireAt } from "./scheduler/cron.js";
|
|
15
15
|
import { bus } from "./bus.js";
|
|
16
|
-
import { fetchModelList, contextLengthFor, isReasoningEffort, } from "./model-meta.js";
|
|
16
|
+
import { fetchModelList, peekModelList, contextLengthFor, isReasoningEffort, } from "./model-meta.js";
|
|
17
17
|
/** how deep sub-agent spawning may nest (parent=0, its subs=1, …) */
|
|
18
18
|
const MAX_SPAWN_DEPTH = 3;
|
|
19
|
+
/**
|
|
20
|
+
* How long boot waits for an endpoint's model catalogue (#143).
|
|
21
|
+
*
|
|
22
|
+
* The window and the reasoning-effort gate both come from `/models`, so a boot
|
|
23
|
+
* that waits on it is waiting for metadata, not for anything it needs to start.
|
|
24
|
+
* The lookup is fired concurrently for every distinct endpoint before the agent
|
|
25
|
+
* loop (see prefetchModelLists), so this bound is paid once per ENDPOINT rather
|
|
26
|
+
* than once per agent — and only when the endpoint is slow or dead.
|
|
27
|
+
*/
|
|
28
|
+
const MODEL_META_BOOT_MS = 1_500;
|
|
19
29
|
/** statuses that mean a child has finished working, for #130 */
|
|
20
30
|
const SETTLED = new Set(["idle", "stopped", "waiting", "error"]);
|
|
21
31
|
/**
|
|
@@ -362,6 +372,16 @@ export class Master {
|
|
|
362
372
|
*/
|
|
363
373
|
deferConfigWrites = false;
|
|
364
374
|
configDirty = false;
|
|
375
|
+
/**
|
|
376
|
+
* Endpoints whose catalogue boot has already WAITED for (#143).
|
|
377
|
+
*
|
|
378
|
+
* The wait must be paid once per endpoint, not once per agent. `Promise.race`
|
|
379
|
+
* against a timeout is per-CALL: with a dead endpoint every agent on it paid
|
|
380
|
+
* the full timeout again, because the shared request was still pending. 493
|
|
381
|
+
* agents over one unreachable provider measured 4.4s of boot spent purely
|
|
382
|
+
* idle, for a single request.
|
|
383
|
+
*/
|
|
384
|
+
metaWaited = new Set();
|
|
365
385
|
/** write once and clear the dirty flag — used at the end of a batched section */
|
|
366
386
|
flushConfig() {
|
|
367
387
|
this.configDirty = false;
|
|
@@ -495,6 +515,14 @@ export class Master {
|
|
|
495
515
|
// boot, which is its own silent data loss.
|
|
496
516
|
this.deferConfigWrites = true;
|
|
497
517
|
this.configDirty = false;
|
|
518
|
+
// #143: start every endpoint's model-catalogue lookup NOW, concurrently.
|
|
519
|
+
//
|
|
520
|
+
// `addAgent` awaits this per agent, and the endpoints are shared by many
|
|
521
|
+
// agents (493 agents over 6 providers here). Fired up front, the requests
|
|
522
|
+
// overlap and each subsequent agent finds the promise already in flight or
|
|
523
|
+
// resolved — instead of the requests being serialised behind the agent loop,
|
|
524
|
+
// where a slow endpoint's timeout is paid again and again.
|
|
525
|
+
this.prefetchModelLists();
|
|
498
526
|
try {
|
|
499
527
|
for (const ac of this.config.agents) {
|
|
500
528
|
try {
|
|
@@ -596,6 +624,38 @@ export class Master {
|
|
|
596
624
|
* Restarts reuse the latest existing session; fresh creations never touch
|
|
597
625
|
* an older one's history.
|
|
598
626
|
*/
|
|
627
|
+
/**
|
|
628
|
+
* Fire the model-catalogue lookup for every distinct endpoint, concurrently.
|
|
629
|
+
*
|
|
630
|
+
* `fetchModelList` already shares an in-flight request per baseUrl, so this is
|
|
631
|
+
* about STARTING them together: without it the requests begin one at a time, as
|
|
632
|
+
* the agent loop reaches each provider, and a slow endpoint's timeout is paid
|
|
633
|
+
* serially by every agent on it.
|
|
634
|
+
*
|
|
635
|
+
* Best-effort by design — nothing awaits this. A failure is cached briefly by
|
|
636
|
+
* `fetchModelList`, and an agent whose lookup is not ready simply gets no
|
|
637
|
+
* inferred window, which is the same outcome as today's timeout.
|
|
638
|
+
*/
|
|
639
|
+
prefetchModelLists() {
|
|
640
|
+
const endpoints = new Set();
|
|
641
|
+
const add = (baseUrl, apiKey) => {
|
|
642
|
+
if (!baseUrl)
|
|
643
|
+
return;
|
|
644
|
+
const key = baseUrl.replace(/\/+$/, "");
|
|
645
|
+
if (endpoints.has(key))
|
|
646
|
+
return;
|
|
647
|
+
endpoints.add(key);
|
|
648
|
+
void fetchModelList(baseUrl, apiKey).catch(() => []);
|
|
649
|
+
};
|
|
650
|
+
for (const ac of this.config.agents) {
|
|
651
|
+
const prov = this.config.providers?.[ac.provider ?? this.config.defaultProvider ?? ""];
|
|
652
|
+
add(ac.baseUrl ?? prov?.baseUrl ?? this.config.llm.baseUrl, ac.apiKey ?? prov?.apiKey ?? this.config.llm.apiKey);
|
|
653
|
+
}
|
|
654
|
+
// no configured agents yet (first-run wizard) — still warm the default so the
|
|
655
|
+
// first created agent does not wait
|
|
656
|
+
if (endpoints.size === 0)
|
|
657
|
+
add(this.config.llm.baseUrl, this.config.llm.apiKey);
|
|
658
|
+
}
|
|
599
659
|
async addAgent(ac, opts = {}) {
|
|
600
660
|
// #72: an agent id becomes a DIRECTORY name (sessions/<id>-<hash>), so it
|
|
601
661
|
// must be a safe path segment. Every caller sanitised its own id — the HTTP
|
|
@@ -631,24 +691,33 @@ export class Master {
|
|
|
631
691
|
// spawn-tree depth: computed from the config chain (ac may not be
|
|
632
692
|
// registered yet when this runs — spawnChildFor persists AFTER creation)
|
|
633
693
|
const myDepth = ac.parent ? this.depthOf(ac.parent) + 1 : 0;
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
694
|
+
// Model metadata: the context window (for the compaction budget) and the
|
|
695
|
+
// catalogue entry that gates reasoning_effort support (#47) come from the
|
|
696
|
+
// SAME `/models` list.
|
|
697
|
+
//
|
|
698
|
+
// #143: boot must not WAIT for this. It only tunes the compaction budget and
|
|
699
|
+
// the effort controls, so a missing list costs nothing that matters; what it
|
|
700
|
+
// cost was boot time — measured at 4.4s of pure idle on 493 agents pointed at
|
|
701
|
+
// one unreachable endpoint, because the `Promise.race` timeout below is
|
|
702
|
+
// per-CALL and the shared request stayed pending for every agent.
|
|
703
|
+
//
|
|
704
|
+
// So: use the catalogue if `prefetchModelLists()` has already produced it
|
|
705
|
+
// (the common case — the lookups were fired when boot started), and wait only
|
|
706
|
+
// ONCE per endpoint for the stragglers, where the wait overlaps a request
|
|
707
|
+
// that is already in flight.
|
|
708
|
+
const needsWindow = !ac.contextWindowTokens && !this.config.contextWindowTokens;
|
|
709
|
+
const root = llm.baseUrl.replace(/\/+$/, "");
|
|
710
|
+
let list = peekModelList(llm.baseUrl);
|
|
711
|
+
if (list === undefined && !this.metaWaited.has(root)) {
|
|
712
|
+
this.metaWaited.add(root);
|
|
713
|
+
list = await Promise.race([
|
|
714
|
+
fetchModelList(llm.baseUrl, llm.apiKey),
|
|
715
|
+
// a placeholder so the race can settle without the list
|
|
716
|
+
new Promise((r) => setTimeout(() => r(undefined), MODEL_META_BOOT_MS)),
|
|
644
717
|
]);
|
|
645
|
-
void listPromise.catch(() => []);
|
|
646
718
|
}
|
|
647
|
-
|
|
648
|
-
const modelMeta = (
|
|
649
|
-
fetchModelList(llm.baseUrl, llm.apiKey),
|
|
650
|
-
new Promise((r) => setTimeout(() => r(undefined), 1_500)),
|
|
651
|
-
]))?.find((m) => m.id === llm.model);
|
|
719
|
+
const inferredWindow = needsWindow && list ? contextLengthFor(list, llm.model) : undefined;
|
|
720
|
+
const modelMeta = list?.find((m) => m.id === llm.model);
|
|
652
721
|
const sessionDir = this.resolveSessionDir(ac.id, opts.fresh === true);
|
|
653
722
|
await mkdirSync(sessionDir, { recursive: true });
|
|
654
723
|
// every session dir this master hands out carries an ownership manifest —
|
|
@@ -1200,11 +1269,54 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1200
1269
|
}
|
|
1201
1270
|
return { owner: best?.id ?? null, hadManifest: false };
|
|
1202
1271
|
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Who does this session directory really belong to, according to its LOG?
|
|
1274
|
+
*
|
|
1275
|
+
* The manifest is a CACHE; the log is the truth. Every event carries the
|
|
1276
|
+
* `agent` that wrote it, and only that agent ever appends — so the first
|
|
1277
|
+
* event's value cannot be stale or wrong.
|
|
1278
|
+
*
|
|
1279
|
+
* This matters because ownership decided from the manifest ALONE is how a
|
|
1280
|
+
* whole data directory gets abandoned. A manifest that is missing, unreadable,
|
|
1281
|
+
* or simply wrong made `inferSessionOwner` reject every directory the agent
|
|
1282
|
+
* owned, `findLatestSessionDir` return null, and the boot mint a fresh empty
|
|
1283
|
+
* session — REWRITING the agent's pointer in config.json. Nothing warned, and
|
|
1284
|
+
* the real history was left on disk, orphaned and unreachable.
|
|
1285
|
+
*
|
|
1286
|
+
* Measured after an accidental in-place `addAgent()` run had rewritten 813
|
|
1287
|
+
* manifests: 493 agents were silently moved to empty sessions, and the
|
|
1288
|
+
* mapping back was only recoverable from these `agent` fields.
|
|
1289
|
+
*/
|
|
1290
|
+
logOwner(dirName) {
|
|
1291
|
+
try {
|
|
1292
|
+
const first = readFileSync(path.join(this.sessionsRoot(), dirName, "chat.jsonl"), "utf8")
|
|
1293
|
+
.split("\n")
|
|
1294
|
+
.find((l) => l.trim());
|
|
1295
|
+
if (!first)
|
|
1296
|
+
return null;
|
|
1297
|
+
const parsed = JSON.parse(first);
|
|
1298
|
+
return typeof parsed.agent === "string" && parsed.agent ? parsed.agent : null;
|
|
1299
|
+
}
|
|
1300
|
+
catch {
|
|
1301
|
+
return null; // no log / unreadable head — cannot prove anything
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1203
1304
|
writeSessionManifest(dirPath, agentId) {
|
|
1204
1305
|
try {
|
|
1205
1306
|
const file = path.join(dirPath, "session.json");
|
|
1206
|
-
if (existsSync(file))
|
|
1207
|
-
|
|
1307
|
+
if (existsSync(file)) {
|
|
1308
|
+
// A WRONG manifest must be repairable, or the mistake is permanent: the
|
|
1309
|
+
// old code returned here unconditionally, so one bad value kept every
|
|
1310
|
+
// later boot from recognising the directory (see logOwner()).
|
|
1311
|
+
const current = this.readSessionOwner(path.basename(dirPath));
|
|
1312
|
+
if (current === agentId)
|
|
1313
|
+
return;
|
|
1314
|
+
const proven = this.logOwner(path.basename(dirPath));
|
|
1315
|
+
if (proven !== agentId)
|
|
1316
|
+
return; // not provably ours — leave it alone
|
|
1317
|
+
console.log(`[teapot] repairing session manifest for ${path.basename(dirPath)}: ` +
|
|
1318
|
+
`${JSON.stringify(current)} → ${JSON.stringify(agentId)} (the log says ${JSON.stringify(agentId)})`);
|
|
1319
|
+
}
|
|
1208
1320
|
writeFileSync(file, `${JSON.stringify({ v: 1, agentId }, null, 2)}\n`);
|
|
1209
1321
|
}
|
|
1210
1322
|
catch {
|
|
@@ -1229,8 +1341,15 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1229
1341
|
const empty = [];
|
|
1230
1342
|
for (const d of readdirSync(this.sessionsRoot())) {
|
|
1231
1343
|
const { owner } = this.inferSessionOwner(d, candidateIds);
|
|
1232
|
-
|
|
1344
|
+
// A wrong manifest must not hide a directory: consult the LOG before
|
|
1345
|
+
// rejecting. Without this, one bad manifest value made every dir the
|
|
1346
|
+
// agent owned invisible here, so the caller minted a fresh session and
|
|
1347
|
+
// rewrote config — silently orphaning the real history.
|
|
1348
|
+
const mineByLog = owner !== agentId && this.logOwner(d) === agentId;
|
|
1349
|
+
if (owner !== agentId && !mineByLog)
|
|
1233
1350
|
continue;
|
|
1351
|
+
if (mineByLog)
|
|
1352
|
+
this.writeSessionManifest(path.join(this.sessionsRoot(), d), agentId);
|
|
1234
1353
|
const dirPath = path.join(this.sessionsRoot(), d);
|
|
1235
1354
|
const m = this.sessionMtime(dirPath);
|
|
1236
1355
|
// an EMPTY chat.jsonl means the session was never really used — but it
|
|
@@ -1285,13 +1404,23 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1285
1404
|
const recorded = this.config.agents.find((a) => a.id === agentId)?.session;
|
|
1286
1405
|
if (!fresh && recorded) {
|
|
1287
1406
|
const dir = path.join(root, recorded);
|
|
1288
|
-
//
|
|
1289
|
-
// the manifest
|
|
1407
|
+
// Trust it when it exists and is really THIS agent's dir. Ownership comes
|
|
1408
|
+
// from the manifest, but the LOG is consulted too: a wrong/absent manifest
|
|
1409
|
+
// must not make teapot abandon history it can plainly see (see logOwner).
|
|
1290
1410
|
const owner = this.readSessionOwner(recorded);
|
|
1291
|
-
|
|
1411
|
+
const mineByManifest = owner === null || owner === agentId;
|
|
1412
|
+
const mineByLog = owner !== agentId && this.logOwner(recorded) === agentId;
|
|
1413
|
+
if (existsSync(dir) && (mineByManifest || mineByLog)) {
|
|
1414
|
+
if (mineByLog)
|
|
1415
|
+
this.writeSessionManifest(dir, agentId); // repair it
|
|
1292
1416
|
return dir;
|
|
1293
|
-
|
|
1294
|
-
|
|
1417
|
+
}
|
|
1418
|
+
// the recorded dir is gone, or provably belongs to someone else — fall
|
|
1419
|
+
// through and mint a new one. Say WHICH, and do not do it quietly: this
|
|
1420
|
+
// line is the only trace that an agent changed session.
|
|
1421
|
+
console.log(`[teapot] session ${recorded} for ${agentId} is unusable ` +
|
|
1422
|
+
`(${existsSync(dir) ? `manifest owner ${JSON.stringify(owner)}` : "directory missing"}); ` +
|
|
1423
|
+
`starting a new one`);
|
|
1295
1424
|
}
|
|
1296
1425
|
// Prefer the LATEST session directory that actually carries history.
|
|
1297
1426
|
// This handles the case where a bare-name directory (e.g. from legacy
|
package/dist/model-meta.js
CHANGED
|
@@ -8,11 +8,47 @@ import { providerHeaders } from "./agent/llm.js";
|
|
|
8
8
|
const cache = new Map();
|
|
9
9
|
const inFlight = new Map();
|
|
10
10
|
const TTL = 10 * 60_000;
|
|
11
|
+
/**
|
|
12
|
+
* How long a FAILED lookup is remembered.
|
|
13
|
+
*
|
|
14
|
+
* A failure used to be dropped on the floor (the `catch` returned `[]` without
|
|
15
|
+
* caching), so a dead, unauthorised, or simply slow endpoint was re-fetched for
|
|
16
|
+
* EVERY agent pointed at it. `addAgent` awaits this twice per agent and races it
|
|
17
|
+
* against a 1.5 s timeout, so with 493 agents across a handful of providers the
|
|
18
|
+
* same failing request — and its timeout — repeated hundreds of times. MEASURED
|
|
19
|
+
* at boot: 1883 ms of the 4.6 s was idle, waiting on exactly this.
|
|
20
|
+
*
|
|
21
|
+
* Negative results expire much sooner than positive ones: a provider coming back
|
|
22
|
+
* up should be usable within a minute, not locked out for ten.
|
|
23
|
+
*/
|
|
24
|
+
const NEGATIVE_TTL = 30_000;
|
|
25
|
+
/** is this cached entry still valid? (failures expire sooner) */
|
|
26
|
+
function fresh(entry) {
|
|
27
|
+
return Date.now() - entry.at < (entry.negative ? NEGATIVE_TTL : TTL);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The catalogue if it is ALREADY known, without starting a fetch (#143).
|
|
31
|
+
*
|
|
32
|
+
* Boot uses this so it never waits for metadata it can do without: the context
|
|
33
|
+
* window only tunes the compaction budget, and `supportedParameters` only gates
|
|
34
|
+
* the reasoning-effort controls (#47). `prefetchModelLists()` starts the real
|
|
35
|
+
* lookups in parallel, so an endpoint that answers quickly is usually warm by the
|
|
36
|
+
* time its first agent is created, and a slow or dead one costs no boot time.
|
|
37
|
+
*/
|
|
38
|
+
export function peekModelList(baseUrl) {
|
|
39
|
+
const root = baseUrl.replace(/\/+$/, "");
|
|
40
|
+
const hit = cache.get(root);
|
|
41
|
+
if (!hit)
|
|
42
|
+
return undefined;
|
|
43
|
+
if (!fresh(hit))
|
|
44
|
+
return undefined;
|
|
45
|
+
return hit.negative ? [] : hit.list;
|
|
46
|
+
}
|
|
11
47
|
/** Fetch GET /models from an OpenAI-compatible endpoint (cached ~10 min). */
|
|
12
48
|
export async function fetchModelList(baseUrl, apiKey) {
|
|
13
49
|
const root = baseUrl.replace(/\/+$/, "");
|
|
14
50
|
const hit = cache.get(root);
|
|
15
|
-
if (hit &&
|
|
51
|
+
if (hit && fresh(hit))
|
|
16
52
|
return hit.list;
|
|
17
53
|
// An in-flight lookup is SHARED: without this, two callers racing for the
|
|
18
54
|
// same endpoint each paid the full timeout, and `newSession` in ACP mode
|
|
@@ -63,6 +99,9 @@ export async function fetchModelList(baseUrl, apiKey) {
|
|
|
63
99
|
return list;
|
|
64
100
|
}
|
|
65
101
|
catch {
|
|
102
|
+
// Remember the FAILURE (briefly). Dropping it meant every agent on this
|
|
103
|
+
// endpoint paid the fetch — and up to the 1.5 s race timeout — again.
|
|
104
|
+
cache.set(root, { at: Date.now(), list: [], negative: true });
|
|
66
105
|
return []; // unreachable provider — caller treats as "unknown"
|
|
67
106
|
}
|
|
68
107
|
})();
|