@chatpanel/events 0.95.0 → 0.97.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/citations.js +49 -0
- package/client-prefs.js +4 -0
- package/failover.js +93 -0
- package/index.js +9 -2
- package/model-health.js +182 -0
- package/package.json +7 -1
- package/source-gate.js +92 -0
- package/sources.js +17 -0
- package/team-record.js +4 -1
- package/team-run.js +11 -4
- package/team-worklog.js +2 -2
- package/turn-loop.js +2 -1
package/citations.js
CHANGED
|
@@ -77,3 +77,52 @@ export function linkifyCitations(answer, sources, { heading = 'Sources' } = {})
|
|
|
77
77
|
.join('\n');
|
|
78
78
|
return `${linked.trimEnd()}\n\n**${heading}**\n${list}\n`;
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------------------
|
|
82
|
+
// The collector — what a turn was GIVEN, gathered as its tools return it, and put back
|
|
83
|
+
// under the answer as links. Was the extension's `citationCollector`; the desktop had none,
|
|
84
|
+
// so its answers cited `[1]` with nothing to click.
|
|
85
|
+
// ---------------------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/** Tools whose job is to RETURN material rather than to act on something. */
|
|
88
|
+
export const RETRIEVAL_TOOLS = Object.freeze(new Set(['find', 'source', 'history_search', 'web_search', 'search', 'fetch', 'read_page']));
|
|
89
|
+
|
|
90
|
+
const retrievalName = (name) => String(name || '').replace(/^mcp[_-]/, '').split('__')[0];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Wrap a toolset so every result is read for numbered sources.
|
|
94
|
+
*
|
|
95
|
+
* @param tools `{ execute, … }`; returned as `.tools`, wrapped
|
|
96
|
+
* @param onRetrieved `({ tool, count, chars, sources }) => void` — RETRIEVAL IS INPUT. Called
|
|
97
|
+
* per call that returned material, so the turn shows what it was given and
|
|
98
|
+
* by which tool. A retrieval tool that returned no LINKS still returned
|
|
99
|
+
* material (notes, past chats), and is reported with `count: 0` — silence
|
|
100
|
+
* there would under-report exactly the private sources this makes visible.
|
|
101
|
+
* @returns `{ tools, list(), apply(answer) }`
|
|
102
|
+
*/
|
|
103
|
+
export function createCitationCollector(tools, { onRetrieved = null } = {}) {
|
|
104
|
+
const sources = new Map();
|
|
105
|
+
const collect = (name, out) => {
|
|
106
|
+
const body = typeof out === 'string' ? out : (out?.text || '');
|
|
107
|
+
if (!body) return;
|
|
108
|
+
const found = sourcesFromToolText(body);
|
|
109
|
+
for (const s of found) if (!sources.has(s.rank)) sources.set(s.rank, s);
|
|
110
|
+
if (!onRetrieved) return;
|
|
111
|
+
try {
|
|
112
|
+
if (found.length) onRetrieved({ tool: name, count: found.length, chars: body.length, sources: found.slice(0, 20).map((x) => ({ rank: x.rank, title: x.title || '', url: x.url || '' })) });
|
|
113
|
+
else if (RETRIEVAL_TOOLS.has(retrievalName(name))) onRetrieved({ tool: name, count: 0, chars: body.length, sources: [] });
|
|
114
|
+
} catch { /* reporting never breaks a turn */ }
|
|
115
|
+
};
|
|
116
|
+
const wrapped = tools && typeof tools.execute === 'function'
|
|
117
|
+
? { ...tools, execute: async (name, input, meta) => { const out = await tools.execute(name, input, meta); collect(name, out); return out; } }
|
|
118
|
+
: tools;
|
|
119
|
+
return {
|
|
120
|
+
tools: wrapped,
|
|
121
|
+
list: () => [...sources.values()],
|
|
122
|
+
/** The answer with its `[n]` citations linked and a Sources section — or the answer untouched when nothing was retrieved. */
|
|
123
|
+
apply(answer) {
|
|
124
|
+
if (!sources.size || !answer || typeof answer !== 'string') return answer;
|
|
125
|
+
try { return linkifyCitations(answer, [...sources.values()]); } catch { return answer; }
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
package/client-prefs.js
CHANGED
|
@@ -40,6 +40,10 @@ export const PREF_SECTIONS = Object.freeze([
|
|
|
40
40
|
{ id: 'watch', label: 'Watch', path: ['ui', 'watch'], kind: 'object' },
|
|
41
41
|
{ id: 'meetings', label: 'Meetings', path: null, kind: 'object', keys: ['meetingWindowMin', 'liveNotesIntervalMin', 'alertSound'] },
|
|
42
42
|
{ id: 'redaction', label: 'Redaction (client-side)', path: ['ui', 'piiRedaction'], kind: 'object' },
|
|
43
|
+
// Internal sites (source-gate.js): which hosts are internal and how far their content may
|
|
44
|
+
// travel. A rule the desktop enforces too, or a page internal in the panel is sent
|
|
45
|
+
// anywhere from the desk.
|
|
46
|
+
{ id: 'internalSites', label: 'Internal sites', path: ['privacy'], kind: 'object' },
|
|
43
47
|
]);
|
|
44
48
|
|
|
45
49
|
export const PREF_SECTION_IDS = Object.freeze(PREF_SECTIONS.map((s) => s.id));
|
package/failover.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Run the call, and try the next model when this one cannot answer.
|
|
2
|
+
//
|
|
3
|
+
// A provider that returns "you have depleted your monthly credits" has not failed the
|
|
4
|
+
// request — it has declined it, and there is very likely another model that would say yes.
|
|
5
|
+
// Showing that error to the user when an alternative was available is the router not doing
|
|
6
|
+
// the one job it exists for.
|
|
7
|
+
//
|
|
8
|
+
// ONLY WHEN THE ROUTER CHOSE. If the user picked a specific model, silently answering from a
|
|
9
|
+
// different one would be worse than the error: they asked for that model for a reason.
|
|
10
|
+
//
|
|
11
|
+
// This was the extension's `withFailover`; the desktop had none, so the same decline ended
|
|
12
|
+
// the turn there. What is shared is the attempt loop and its rules — keep trying (bounded),
|
|
13
|
+
// classify before retrying, never announce a model that will not be called, say "N models
|
|
14
|
+
// tried" rather than the last provider's error as if it were the whole story. What is
|
|
15
|
+
// injected is how the host picks the next model (its router, its roster) and how it tells
|
|
16
|
+
// the user.
|
|
17
|
+
//
|
|
18
|
+
// Class R with an async seam: no I/O of its own.
|
|
19
|
+
|
|
20
|
+
/** Enough to work through a realistic set of models rather than sampling it — but bounded, because sitting through every failure is its own kind of broken. */
|
|
21
|
+
export const FAILOVER_MAX_ATTEMPTS = 6;
|
|
22
|
+
|
|
23
|
+
/** The terminal message: what was tried, and the last thing that went wrong. */
|
|
24
|
+
export function failoverExhausted(tried, err) {
|
|
25
|
+
const n = Array.isArray(tried) ? tried.length : Number(tried) || 0;
|
|
26
|
+
const e = new Error(`${n} model${n === 1 ? '' : 's'} tried, none could answer. Last error — ${err?.message || err}`);
|
|
27
|
+
e.cause = err;
|
|
28
|
+
e.tried = Array.isArray(tried) ? [...tried] : [];
|
|
29
|
+
return e;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param first the target to call first — `{ id, model, name?, routedVia? }` plus whatever the host's call needs
|
|
34
|
+
* @param call `(target) => Promise<result>` — MUST throw on a failure (an Error with
|
|
35
|
+
* `status` when the host has one); a host whose call returns `{ ok: false }`
|
|
36
|
+
* converts before handing it here
|
|
37
|
+
* @param chose did the router choose `first`? When false, a failure is the answer.
|
|
38
|
+
* @param health a model-health ledger (`createModelHealth`)
|
|
39
|
+
* @param next `async ({ current, tried, reason, marked }) => target | null` — the host's
|
|
40
|
+
* router: the same class of thing, excluding what already failed
|
|
41
|
+
* @param onHop `({ from, to, reason, reasons, next }) => void` — tell the user, record it
|
|
42
|
+
* @param labelOf `(target) => string` for messages
|
|
43
|
+
* @param signal an aborted signal ends the chain with the current error
|
|
44
|
+
*/
|
|
45
|
+
export async function runWithFailover({
|
|
46
|
+
first, call, chose = false, health = null, next = null, onHop = null, labelOf = defaultLabel, signal = null,
|
|
47
|
+
maxAttempts = FAILOVER_MAX_ATTEMPTS,
|
|
48
|
+
} = {}) {
|
|
49
|
+
if (typeof call !== 'function') throw new Error('runWithFailover: call required');
|
|
50
|
+
const tried = [];
|
|
51
|
+
let current = first;
|
|
52
|
+
let lastErr = null;
|
|
53
|
+
|
|
54
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
55
|
+
try {
|
|
56
|
+
const out = await call(current);
|
|
57
|
+
if (current?.id && health) health.markHealthy(current.id);
|
|
58
|
+
return out;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
lastErr = err;
|
|
61
|
+
if (!chose || signal?.aborted || !health || typeof next !== 'function') throw err;
|
|
62
|
+
// The model name goes with the report, so "this model fails everywhere" is learnable
|
|
63
|
+
// rather than rediscovered at each provider in turn.
|
|
64
|
+
const marked = health.markUnhealthy(current?.id, err, current?.model);
|
|
65
|
+
if (!marked) throw err;
|
|
66
|
+
tried.push(current?.id);
|
|
67
|
+
|
|
68
|
+
// Do not ANNOUNCE a model we are not going to call. The loop used to pick and announce
|
|
69
|
+
// the next one and only then discover it was out of attempts, so the chain named a
|
|
70
|
+
// model that never ran and the error shown came from the hop before it.
|
|
71
|
+
if (attempt === maxAttempts - 1) throw failoverExhausted(tried, err);
|
|
72
|
+
|
|
73
|
+
const to = await next({ current, tried: [...tried], reason: marked.reason, marked, error: err });
|
|
74
|
+
// Genuinely out of options. Say that, rather than showing the last provider's error as
|
|
75
|
+
// though it were the whole story — "Groq says no" and "every model you have said no"
|
|
76
|
+
// are different problems with different fixes.
|
|
77
|
+
if (!to) throw failoverExhausted(tried, err);
|
|
78
|
+
|
|
79
|
+
const hop = {
|
|
80
|
+
from: labelOf(current), to: labelOf(to), reason: marked.reason,
|
|
81
|
+
reasons: [`${labelOf(current)} declined (${marked.reason})`, ...(to.routedVia?.reasons || [])],
|
|
82
|
+
next: to,
|
|
83
|
+
};
|
|
84
|
+
try { onHop?.(hop); } catch { /* telling is best effort */ }
|
|
85
|
+
current = to;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw lastErr;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function defaultLabel(t) {
|
|
92
|
+
return t?.routedVia?.model || t?.name || t?.label || t?.model || t?.id || 'model';
|
|
93
|
+
}
|
package/index.js
CHANGED
|
@@ -119,12 +119,12 @@ export {
|
|
|
119
119
|
} from './observability.js';
|
|
120
120
|
export { routeGraph, projectChain } from './route-graph.js';
|
|
121
121
|
export { defineAdapter, createAdapterRegistry, AdapterError } from './adapters.js';
|
|
122
|
-
export { linkifyCitations, sourcesFromToolText } from './citations.js';
|
|
122
|
+
export { linkifyCitations, sourcesFromToolText, createCitationCollector, RETRIEVAL_TOOLS } from './citations.js';
|
|
123
123
|
export { buildTrajectory, phasesOf, lanesOf, filterEntries, displayName, ENTRY_KINDS, threadsOf, threadTitle, promptEntries, turnsOf, threadTree } from './trajectory.js';
|
|
124
124
|
export { createTurnRunner, defineLoop, LOOP_KINDS, LoopError } from './loop.js';
|
|
125
125
|
export { defineModel, defineMiddleware, defineRouteStrategy, createModelRouter, signalsFrom, requirementsFor, requirementsForStep, preferenceFor, failoverOrder, pinnedOrderOf, FAILOVER_CLASS_GAP, FAILOVER_CAPABILITY_GAP, sameModelKey, RouterError } from './router.js';
|
|
126
126
|
export { makeSourceStore, manifestText, shortUrl, readSource, sourceId } from './sources-retrieval.js';
|
|
127
|
-
export { classifySource, extractUrls, hostMatches, meetReach, sourcePolicyFor, DEFAULT_INTERNAL_PATTERNS, INTERNAL_PATTERN_CATALOG } from './sources.js';
|
|
127
|
+
export { classifySource, extractUrls, hostMatches, meetReach, sourcePolicyFor, sourceUrlsOf, DEFAULT_INTERNAL_PATTERNS, INTERNAL_PATTERN_CATALOG } from './sources.js';
|
|
128
128
|
export { defineRule, createRuleEngine, SUPPRESSED, RuleError } from './rules.js';
|
|
129
129
|
export {
|
|
130
130
|
VAULT_VERSION, KDF_ITERATIONS, KDF_HASH, DEFAULT_LOCK_MS, VaultError,
|
|
@@ -178,6 +178,13 @@ export {
|
|
|
178
178
|
createToolLoopGuard, roundSignature, stableToolCallKey, toolMadeProgress, isLoopableTool, blockedToolResult,
|
|
179
179
|
OBSERVATION_TOOLS, INPUT_PROGRESS_TOOLS,
|
|
180
180
|
} from './tool-loop-guard.js';
|
|
181
|
+
// Failover — the attempt loop and the health ledger behind it; the host's router picks the
|
|
182
|
+
// next model.
|
|
183
|
+
export { classifyFailure, createModelHealth, COOLDOWN_MS, UNAVAILABLE_REASONS, normModelName } from './model-health.js';
|
|
184
|
+
export { runWithFailover, failoverExhausted, FAILOVER_MAX_ATTEMPTS } from './failover.js';
|
|
185
|
+
// The source ceiling as a gate, and the citation collector — the last two pieces of the turn
|
|
186
|
+
// wrapper that were the extension's alone.
|
|
187
|
+
export { sourcePolicySettings, sourceGuardFor, sourceGate, isSourceGateError, withinReach } from './source-gate.js';
|
|
181
188
|
export {
|
|
182
189
|
runTurnLoop, createCallRunner, roundCap, withToolSystem, describeCall as describeToolCall, stepResultText, addUsage, normalizeUsage,
|
|
183
190
|
openAiTranscript, anthropicTranscript,
|
package/model-health.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// What a provider failure MEANS, and how long to stand the model down for it.
|
|
2
|
+
//
|
|
3
|
+
// Lived in the extension as `js/model-health.js`, wrapped around chrome.storage.session.
|
|
4
|
+
// The desktop could not read it and so could not fail over at all: a model that returned
|
|
5
|
+
// "you have depleted your monthly credits" ended the turn there, while the extension would
|
|
6
|
+
// have moved to the next one. The classifier and the ledger are pure; only the persistence
|
|
7
|
+
// was the extension's, and that is injected now.
|
|
8
|
+
//
|
|
9
|
+
// Only the categories that change what to DO are distinguished. A 402 and a 429 are both
|
|
10
|
+
// "not now", but one is "not for a while" and the other is "in a moment", and routing that
|
|
11
|
+
// treats them the same either hammers a dead endpoint or abandons a live one.
|
|
12
|
+
//
|
|
13
|
+
// Class R: strings in, a category and a deadline out. The clock is injected.
|
|
14
|
+
|
|
15
|
+
const MODEL_STANDDOWN_MS = 30 * 60_000;
|
|
16
|
+
|
|
17
|
+
/** How long to stand a model down, by what went wrong. */
|
|
18
|
+
export const COOLDOWN_MS = Object.freeze({
|
|
19
|
+
quota: 30 * 60_000,
|
|
20
|
+
rate: 60_000,
|
|
21
|
+
server: 2 * 60_000,
|
|
22
|
+
// The model is gone — retired, removed, renamed. It is not coming back, so standing it
|
|
23
|
+
// down for the rest of the session is the honest answer; anything shorter just repeats the
|
|
24
|
+
// same failure on a timer.
|
|
25
|
+
gone: 24 * 60 * 60_000,
|
|
26
|
+
// The account needs reconnecting — a human action, on no timetable. Retrying on a short
|
|
27
|
+
// timer just walks the chain back into the same wall every turn.
|
|
28
|
+
auth: 6 * 60 * 60_000,
|
|
29
|
+
// A request this provider would not take. Another may; this one probably still will not,
|
|
30
|
+
// but it is worth re-checking well before an auth problem.
|
|
31
|
+
request: 10 * 60_000,
|
|
32
|
+
// Nobody is listening. A local model that is not running, a hostname that does not
|
|
33
|
+
// resolve, a server that refuses the connection. It is not coming back on a 30-second
|
|
34
|
+
// timer — someone has to start the thing — and re-dialling it every turn was the exact
|
|
35
|
+
// failure a user watched as ERR_CONNECTION_REFUSED, twice, on two different pages.
|
|
36
|
+
unreachable: 5 * 60_000,
|
|
37
|
+
// Anything else — treat as transient and barely stand it down at all.
|
|
38
|
+
unknown: 30_000,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** The reasons that mean "not available" rather than "not right now". */
|
|
42
|
+
export const UNAVAILABLE_REASONS = Object.freeze(['quota', 'server', 'gone', 'auth', 'request', 'unreachable']);
|
|
43
|
+
|
|
44
|
+
export const normModelName = (m) => String(m || '').toLowerCase().replace(/^[^/]+\//, '').replace(/[:@].*$/, '').replace(/[^a-z0-9.]+/g, '');
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Classify a provider failure — an Error, or a `{ status, message }` the host built from a
|
|
48
|
+
* response it did not throw on.
|
|
49
|
+
*/
|
|
50
|
+
export function classifyFailure(err) {
|
|
51
|
+
const text = String(err?.message || err?.error || err || '');
|
|
52
|
+
const status = Number(err?.status) || Number(/\b(4\d\d|5\d\d)\b/.exec(text)?.[1]) || 0;
|
|
53
|
+
if (status === 402 || /credit|quota|billing|payment required|depleted/i.test(text)) return 'quota';
|
|
54
|
+
if (status === 429 || /rate.?limit|too many requests/i.test(text)) return 'rate';
|
|
55
|
+
// THE MODEL IS GONE, not our request. A 410 saying "reached its end of life", a 404 on the
|
|
56
|
+
// model name, a deprecation notice — every other model would handle this request fine, so
|
|
57
|
+
// failing the turn is the one response that helps nobody. Checked BEFORE the generic 4xx
|
|
58
|
+
// rule, which would otherwise read this as our mistake and refuse to fail over.
|
|
59
|
+
if (status === 410
|
|
60
|
+
|| /end of life|no longer available|has been (retired|deprecated|removed)|decommissioned/i.test(text)
|
|
61
|
+
// "The model X does not exist or you do not have access to it" — a 404 naming a model is
|
|
62
|
+
// the provider saying THIS model is unusable, not that our request was malformed.
|
|
63
|
+
|| /model.*(does not exist|not found|no access|do not have access)|unknown model|no such model/i.test(text)
|
|
64
|
+
// An agent configured for a model it does not have. Nothing about that changes in thirty
|
|
65
|
+
// seconds, and retrying it costs a process spawn to be told the same thing.
|
|
66
|
+
|| /invalid model selection|not recognized as a (known|custom) model|unsupported model/i.test(text)) return 'gone';
|
|
67
|
+
if (status >= 500 || /overloaded|unavailable|timeout|ECONNRESET/i.test(text)) return 'server';
|
|
68
|
+
// NOBODY IS LISTENING. A browser fetch to a dead endpoint throws TypeError: Failed to fetch
|
|
69
|
+
// (Safari: "Load failed"); Node says ECONNREFUSED. None carry a status.
|
|
70
|
+
if (/failed to fetch|load failed|networkerror|network error|connection refused|ECONNREFUSED|ERR_CONNECTION|ENOTFOUND|EHOSTUNREACH|ECONNABORTED|couldn't reach the gateway|gateway is not answering/i.test(text)) return 'unreachable';
|
|
71
|
+
// A BROKEN CONNECTION IS THIS PROVIDER'S, NOT THE REQUEST'S. An expired refresh token —
|
|
72
|
+
// "OAuth token exchange failed: HTTP 400 — invalid_grant" — says this provider's
|
|
73
|
+
// credentials went stale, and every other model would have answered the question fine.
|
|
74
|
+
if (status === 401 || status === 403
|
|
75
|
+
|| /oauth|invalid[_ ]?grant|refresh[_ ]?token|token exchange|api[_ ]?key|unauthorized|not authenticated|authentication|credential|expired token|sign in|log ?in again/i.test(text)) {
|
|
76
|
+
return 'auth';
|
|
77
|
+
}
|
|
78
|
+
// A plain 400 usually IS a malformed request — but providers reject each other's
|
|
79
|
+
// parameters, tool schemas and sampling settings all the time. Failing over costs one
|
|
80
|
+
// extra attempt; dead-ending costs the user their turn.
|
|
81
|
+
if (status === 400) return 'request';
|
|
82
|
+
// Everything else gets tried elsewhere. A router that gives up on an unrecognised failure
|
|
83
|
+
// is a router that gives up.
|
|
84
|
+
return 'unknown';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The health ledger: which models are standing down, and why.
|
|
89
|
+
*
|
|
90
|
+
* @param now clock
|
|
91
|
+
* @param onChange `(snapshot) => void` — the host persists it (the extension: the session
|
|
92
|
+
* area; the desktop: memory). Called after every change.
|
|
93
|
+
*/
|
|
94
|
+
export function createModelHealth({ now = () => Date.now(), onChange = null } = {}) {
|
|
95
|
+
const health = new Map(); // id -> { until, reason, failures }
|
|
96
|
+
const byModel = new Map(); // normalised model name -> { providers:Set, until, reason }
|
|
97
|
+
|
|
98
|
+
const snapshot = () => ({
|
|
99
|
+
health: [...health].map(([id, h]) => [id, h]),
|
|
100
|
+
byModel: [...byModel].map(([k, m]) => [k, { providers: [...m.providers], until: m.until, reason: m.reason }]),
|
|
101
|
+
});
|
|
102
|
+
const changed = () => { try { onChange?.(snapshot()); } catch { /* persistence is best effort */ } };
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
snapshot,
|
|
106
|
+
|
|
107
|
+
/** Load what another context already learned. Never overwrites what this one knows. */
|
|
108
|
+
hydrate(snap) {
|
|
109
|
+
if (!snap) return false;
|
|
110
|
+
const t = now();
|
|
111
|
+
for (const [id, h] of snap.health || []) if (h?.until > t && !health.has(id)) health.set(id, h);
|
|
112
|
+
for (const [k, m] of snap.byModel || []) {
|
|
113
|
+
if (!m || byModel.has(k)) continue;
|
|
114
|
+
byModel.set(k, { providers: new Set(m.providers || []), until: m.until || 0, reason: m.reason || null });
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
/** Record that a model failed, and stand it down for as long as that failure warrants. */
|
|
120
|
+
markUnhealthy(id, err, modelName = '') {
|
|
121
|
+
const reason = classifyFailure(err);
|
|
122
|
+
if (!id || !reason) return null;
|
|
123
|
+
// Learn about the MODEL, not only the endpoint. A model that is gone is gone everywhere,
|
|
124
|
+
// so one report is enough; anything else needs two providers to agree before we believe
|
|
125
|
+
// it is the model rather than the provider.
|
|
126
|
+
const key = normModelName(modelName);
|
|
127
|
+
if (key) {
|
|
128
|
+
const seen = byModel.get(key) || { providers: new Set(), until: 0, reason: null };
|
|
129
|
+
seen.providers.add(id);
|
|
130
|
+
if (reason === 'gone' || seen.providers.size >= 2) {
|
|
131
|
+
seen.until = now() + MODEL_STANDDOWN_MS;
|
|
132
|
+
seen.reason = reason;
|
|
133
|
+
}
|
|
134
|
+
byModel.set(key, seen);
|
|
135
|
+
}
|
|
136
|
+
const prev = health.get(id);
|
|
137
|
+
const failures = (prev?.failures || 0) + 1;
|
|
138
|
+
// Repeated failures extend the wait, capped — a model failing every time should be
|
|
139
|
+
// tried rarely, not never. The cap never shortens the base: capping a 24-hour
|
|
140
|
+
// stand-down at an hour would retry a model that no longer exists, 23 times a day.
|
|
141
|
+
const base = COOLDOWN_MS[reason] || COOLDOWN_MS.unknown;
|
|
142
|
+
const ceiling = Math.max(base, 60 * 60_000);
|
|
143
|
+
const until = now() + Math.min(base * failures, ceiling);
|
|
144
|
+
health.set(id, { until, reason, failures });
|
|
145
|
+
changed();
|
|
146
|
+
return { reason, until };
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/** A model answered, so whatever was wrong is over. */
|
|
150
|
+
markHealthy(id) {
|
|
151
|
+
if (id && health.has(id)) { health.delete(id); changed(); }
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
/** `{ available, rateLimited, reason, until? }` for the router. Unknown models are healthy. */
|
|
155
|
+
healthOf(id, modelName = '') {
|
|
156
|
+
const key = normModelName(modelName);
|
|
157
|
+
if (key) {
|
|
158
|
+
const m = byModel.get(key);
|
|
159
|
+
if (m && m.until && now() < m.until) return { available: false, rateLimited: false, reason: m.reason, until: m.until, model: true };
|
|
160
|
+
}
|
|
161
|
+
const h = health.get(id);
|
|
162
|
+
if (!h || now() >= h.until) {
|
|
163
|
+
if (h) health.delete(id); // expired; forget it rather than carrying dead state
|
|
164
|
+
return { available: true, rateLimited: false, reason: null };
|
|
165
|
+
}
|
|
166
|
+
// A rate limit is "not right now"; everything else on the list is "not available". The
|
|
167
|
+
// router rejects both; the reason it shows the user differs.
|
|
168
|
+
return { available: !UNAVAILABLE_REASONS.includes(h.reason), rateLimited: h.reason === 'rate', reason: h.reason, until: h.until };
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/** What is currently stood down, for a settings page. */
|
|
172
|
+
unhealthyModels() {
|
|
173
|
+
const out = [];
|
|
174
|
+
const t = now();
|
|
175
|
+
for (const [id, h] of health) if (t < h.until) out.push({ id, ...h });
|
|
176
|
+
return out;
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
/** Forget everything. */
|
|
180
|
+
reset() { health.clear(); byModel.clear(); changed(); },
|
|
181
|
+
};
|
|
182
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.97.0",
|
|
4
4
|
"description": "The canonical ChatPanel event-log and capability contracts — typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"./entity.js": "./entity.js",
|
|
27
27
|
"./event.js": "./event.js",
|
|
28
28
|
"./extraction.js": "./extraction.js",
|
|
29
|
+
"./failover.js": "./failover.js",
|
|
29
30
|
"./find-tool.js": "./find-tool.js",
|
|
30
31
|
"./flowchart.js": "./flowchart.js",
|
|
31
32
|
"./harness.js": "./harness.js",
|
|
@@ -49,6 +50,7 @@
|
|
|
49
50
|
"./meeting-text.js": "./meeting-text.js",
|
|
50
51
|
"./memory.js": "./memory.js",
|
|
51
52
|
"./model-candidates.js": "./model-candidates.js",
|
|
53
|
+
"./model-health.js": "./model-health.js",
|
|
52
54
|
"./model-ledger.js": "./model-ledger.js",
|
|
53
55
|
"./model-picker.js": "./model-picker.js",
|
|
54
56
|
"./note-actions.js": "./note-actions.js",
|
|
@@ -86,6 +88,7 @@
|
|
|
86
88
|
"./skill-vars.js": "./skill-vars.js",
|
|
87
89
|
"./slash-commands.js": "./slash-commands.js",
|
|
88
90
|
"./sources-retrieval.js": "./sources-retrieval.js",
|
|
91
|
+
"./source-gate.js": "./source-gate.js",
|
|
89
92
|
"./sources.js": "./sources.js",
|
|
90
93
|
"./store.js": "./store.js",
|
|
91
94
|
"./structured.js": "./structured.js",
|
|
@@ -161,6 +164,7 @@
|
|
|
161
164
|
"entity.js",
|
|
162
165
|
"event.js",
|
|
163
166
|
"extraction.js",
|
|
167
|
+
"failover.js",
|
|
164
168
|
"find-tool.js",
|
|
165
169
|
"flowchart.js",
|
|
166
170
|
"harness.js",
|
|
@@ -185,6 +189,7 @@
|
|
|
185
189
|
"meeting-text.js",
|
|
186
190
|
"memory.js",
|
|
187
191
|
"model-candidates.js",
|
|
192
|
+
"model-health.js",
|
|
188
193
|
"model-ledger.js",
|
|
189
194
|
"model-picker.js",
|
|
190
195
|
"note-actions.js",
|
|
@@ -222,6 +227,7 @@
|
|
|
222
227
|
"skill-vars.js",
|
|
223
228
|
"slash-commands.js",
|
|
224
229
|
"sources-retrieval.js",
|
|
230
|
+
"source-gate.js",
|
|
225
231
|
"sources.js",
|
|
226
232
|
"store.js",
|
|
227
233
|
"structured.js",
|
package/source-gate.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The source ceiling — would this turn send internal material to a model that is too far
|
|
2
|
+
// away? — as a GATE, not a ranking.
|
|
3
|
+
//
|
|
4
|
+
// Routing is not enough. A router returns null in every uncertain case and null means
|
|
5
|
+
// "leave the choice alone", which is right for a preference and catastrophic for a privacy
|
|
6
|
+
// rule: an internal page with no local model available would have gone to the third party
|
|
7
|
+
// the user had selected. And routing only runs under Auto, so a manually chosen cloud model
|
|
8
|
+
// bypassed it entirely. So this runs on every turn, after routing has had its chance to
|
|
9
|
+
// pick something local, and it REFUSES rather than substituting: silently answering from a
|
|
10
|
+
// different model is the substitution this codebase keeps removing, and silently sending
|
|
11
|
+
// anyway is the leak it exists to stop.
|
|
12
|
+
//
|
|
13
|
+
// Was the extension's `sourceGate` + `sourcePolicySettings` + `sourceUrlsOf`; the desktop
|
|
14
|
+
// had none of it and sent internal pages wherever the picker pointed. The policy (which
|
|
15
|
+
// hosts are internal, how far their content may travel) is the user's and travels in the
|
|
16
|
+
// shared `internalSites` preference section, so both clients enforce the same rule.
|
|
17
|
+
//
|
|
18
|
+
// Class R: no I/O. The target's reach comes from `reachOf` (a bridge agent is 'trusted', a
|
|
19
|
+
// localhost endpoint 'device', the rest 'any') unless the caller already knows it.
|
|
20
|
+
|
|
21
|
+
import { sourcePolicyFor, sourceUrlsOf, DEFAULT_INTERNAL_PATTERNS } from './sources.js';
|
|
22
|
+
import { reachOf } from './model-candidates.js';
|
|
23
|
+
import { REACH, reachRank } from './reach.js';
|
|
24
|
+
|
|
25
|
+
// `sourceUrlsOf` lives in sources.js (no router on its graph) so a client's first paint can
|
|
26
|
+
// list a turn's addresses without pulling the router in; it is re-exported here for callers
|
|
27
|
+
// that have the gate anyway.
|
|
28
|
+
export { sourceUrlsOf };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The internal-source policy, read from the `privacy` settings object in ONE place.
|
|
32
|
+
*
|
|
33
|
+
* NEVER CONFIGURED and CONFIGURED TO NOTHING are different answers. Undefined means the
|
|
34
|
+
* user has not been here yet, so the built-ins apply; an array — even an empty one — is a
|
|
35
|
+
* list they edited, and prepending our own to it would make a default impossible to
|
|
36
|
+
* remove. Someone testing against localhost has a real reason to delete that line.
|
|
37
|
+
*/
|
|
38
|
+
export function sourcePolicySettings(privacy = {}) {
|
|
39
|
+
const cfg = privacy || {};
|
|
40
|
+
const saved = cfg.internalPatterns;
|
|
41
|
+
const list = Array.isArray(saved)
|
|
42
|
+
? saved
|
|
43
|
+
: (saved == null ? DEFAULT_INTERNAL_PATTERNS : String(saved).split(/[\s,]+/));
|
|
44
|
+
return {
|
|
45
|
+
enabled: cfg.internalGuard !== false,
|
|
46
|
+
patterns: list.map((x) => String(x || '').trim().toLowerCase()).filter(Boolean),
|
|
47
|
+
ceiling: cfg.internalCeiling === 'trusted' ? 'trusted' : 'device',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What the sources of a turn allow. Null when the guard is off or nothing matched. */
|
|
52
|
+
export function sourceGuardFor(policy, sources = []) {
|
|
53
|
+
if (!policy || !policy.enabled || !sources?.length) return null;
|
|
54
|
+
const p = sourcePolicyFor(sources, { patterns: policy.patterns, ceiling: policy.ceiling });
|
|
55
|
+
return p.internal ? p : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The gate. `{ blocked, why, reach, message }` when the target is out of reach for these
|
|
60
|
+
* sources; null when the turn may go.
|
|
61
|
+
*
|
|
62
|
+
* @param policy `sourcePolicySettings(privacy)`
|
|
63
|
+
* @param messages the conversation as it will be sent
|
|
64
|
+
* @param sources anything else the caller knows was attached
|
|
65
|
+
* @param target `{ kind, baseUrl }` (reach computed) or `{ reach }` (reach known)
|
|
66
|
+
* @param label how to name the target in the message
|
|
67
|
+
*/
|
|
68
|
+
export function sourceGate({ policy, messages = [], sources = [], target = {}, label = '' } = {}) {
|
|
69
|
+
const guard = sourceGuardFor(policy, sourceUrlsOf(messages, sources));
|
|
70
|
+
if (!guard) return null;
|
|
71
|
+
const actual = REACH.includes(target?.reach) ? target.reach : reachOf(target || {});
|
|
72
|
+
if (reachRank(actual) <= reachRank(guard.reach)) return null;
|
|
73
|
+
const where = guard.reach === 'device' ? 'stay on this device' : 'stay inside your workspace';
|
|
74
|
+
return {
|
|
75
|
+
blocked: true,
|
|
76
|
+
why: guard.why,
|
|
77
|
+
reach: guard.reach,
|
|
78
|
+
// Name the source, the model and the way out. A refusal a person cannot act on gets
|
|
79
|
+
// switched off wholesale, which would leave them worse protected than before.
|
|
80
|
+
message: `Not sent: ${guard.why}. "${label || 'this model'}" is outside that, and content from an internal source must ${where}. `
|
|
81
|
+
+ 'Pick a local model (or run one), or remove this site under Settings → Privacy → Internal sites.',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Is this error the gate's refusal? A runner reads it as "this model, not this task". */
|
|
86
|
+
export const isSourceGateError = (err) => /^Not sent: /.test(String(err?.message || err || ''));
|
|
87
|
+
|
|
88
|
+
/** The candidates a turn under `guard` may be handed at all — for a roster, before appointment. */
|
|
89
|
+
export function withinReach(candidates = [], guard = null) {
|
|
90
|
+
if (!guard) return candidates;
|
|
91
|
+
return (candidates || []).filter((c) => reachRank(REACH.includes(c?.reach) ? c.reach : reachOf(c || {})) <= reachRank(guard.reach));
|
|
92
|
+
}
|
package/sources.js
CHANGED
|
@@ -254,3 +254,20 @@ export function sourcePolicyFor(sources = [], { patterns, ceiling = 'device', ba
|
|
|
254
254
|
why: `${hits[0].host || 'the source'} matches '${hits[0].matched}' — kept ${safeCeiling === 'device' ? 'on this device' : 'inside your workspace'}`,
|
|
255
255
|
};
|
|
256
256
|
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Every address a conversation carries: each attachment's `url`, every URL in a message
|
|
260
|
+
* body (an internal link pasted into a message — or arriving in a tool result written back
|
|
261
|
+
* into the conversation — is internal material just as much as an attachment is), plus
|
|
262
|
+
* anything the caller states outright. The WHOLE conversation, because an internal page
|
|
263
|
+
* attached three turns ago is still in the text being sent now.
|
|
264
|
+
*/
|
|
265
|
+
export function sourceUrlsOf(messages, extraSources = []) {
|
|
266
|
+
const urls = [];
|
|
267
|
+
for (const m of messages || []) {
|
|
268
|
+
for (const a of m?.attachments || []) if (a?.url) urls.push(a.url);
|
|
269
|
+
for (const u of extractUrls(typeof m?.content === 'string' ? m.content : '')) urls.push(u);
|
|
270
|
+
}
|
|
271
|
+
for (const s of extraSources || []) if (s) urls.push(typeof s === 'string' ? s : (s.url || s.href || ''));
|
|
272
|
+
return urls.filter(Boolean);
|
|
273
|
+
}
|
package/team-record.js
CHANGED
|
@@ -70,7 +70,10 @@ export function foldRun(run, ev) {
|
|
|
70
70
|
if (t) { t.status = 'running'; t.startedAt = at; t.error = null; }
|
|
71
71
|
run.status = 'running'; break;
|
|
72
72
|
}
|
|
73
|
-
case 'task.model': { const t = taskOf(run, p.taskId); if (t) { t.model = p.model; t.attempts = [...(t.attempts || []), { model: p.model, at, attempt: p.attempt }]; } break; }
|
|
73
|
+
case 'task.model': { const t = taskOf(run, p.taskId); if (t) { t.model = p.model; t.attempts = [...(t.attempts || []), { model: p.model, ...(p.label ? { label: p.label } : {}), at, attempt: p.attempt }]; } break; }
|
|
74
|
+
// Which engine the attempt runs on (pillars §13): kept on the attempt, so a record folded
|
|
75
|
+
// from events names the model the way the runner's own `attempts` do.
|
|
76
|
+
case 'task.routed': { const t = taskOf(run, p.taskId); const a = t?.attempts?.at?.(-1); if (a && p.engine && (a.attempt == null || a.attempt === p.attempt)) a.engine = p.engine; break; }
|
|
74
77
|
case 'task.step': { const t = taskOf(run, p.taskId); if (t && Array.isArray(p.steps)) t.transcript = [...(t.transcript || []), ...p.steps]; break; }
|
|
75
78
|
case 'task.handoff': { const t = taskOf(run, p.taskId); if (t) { t.model = p.to; t.handoffs = [...(t.handoffs || []), { from: p.from, to: p.to, by: p.by, reason: p.reason, at }]; } break; }
|
|
76
79
|
case 'task.tool': { const t = taskOf(run, p.taskId); if (t) t.tools = (t.tools || 0) + 1; break; }
|
package/team-run.js
CHANGED
|
@@ -69,6 +69,9 @@ export function isModelUnavailable(error) {
|
|
|
69
69
|
// fetch" (the extension reports it as "network error") — a local model killed mid-run
|
|
70
70
|
// arrived as the latter and ended the task on its first attempt with Claude Code sitting
|
|
71
71
|
// idle on the roster.
|
|
72
|
+
// The source gate's refusal (source-gate.js) is about THIS model's reach, not the task:
|
|
73
|
+
// the next appointment, within reach, can do it.
|
|
74
|
+
if (/^Not sent: /.test(m)) return true;
|
|
72
75
|
return /model[_ ]not[_ ]found|not found|not deployed|inaccessible|does not exist|no such model|unknown model|unsupported model|not available|unavailable|no api key|not configured|"status":\s*(404|401|403|500|502|503)\b|\b(404|401|403|502|503)\b|exited \d+|returned no answer|did not answer|closed the connection|couldn't reach|could not reach|ECONNREFUSED|ECONNRESET|ENOTFOUND|EHOSTUNREACH|socket hang up|fetch failed|failed to fetch|load failed|network ?error|overloaded|capacity/i.test(m);
|
|
73
76
|
}
|
|
74
77
|
|
|
@@ -414,7 +417,9 @@ export async function runTeam({
|
|
|
414
417
|
// A model that is not there — not deployed, no key, gone — is not the task failing:
|
|
415
418
|
// the next model on the roster is appointed and the task tried again, up to three
|
|
416
419
|
// models. Anything else (a refusal, a timeout, a bad request) fails the task.
|
|
417
|
-
|
|
420
|
+
// A resumed task does not walk back into the models that were unavailable the last
|
|
421
|
+
// time it ran — that is how one task read `A → B → A → B → A → B` on the board.
|
|
422
|
+
const exclude = new Set((was?.attempts || []).filter((a) => a && a.model && a.status === 'error' && isModelUnavailable(a.error)).map((a) => a.model));
|
|
418
423
|
let lastErr = '';
|
|
419
424
|
// What the next attempt is told, when it continues rather than starts.
|
|
420
425
|
let note = was ? continuationNote(was.status === 'waiting' ? { kind: 'answer', answer: 'see the board' } : { kind: 'resume', reason: was.error || was.status }) : null;
|
|
@@ -440,10 +445,12 @@ export async function runTeam({
|
|
|
440
445
|
if (!m?.model) throw new Error(exclude.size ? `no model left for role "${role.id}" after ${[...exclude].join(', ')}` : `no model for role "${role.id}"`);
|
|
441
446
|
if (attempt > 1 && lastErr) say('task.reappointed', { taskId: task.id, role: role.id, model: m.model, after: [...exclude], error: lastErr });
|
|
442
447
|
// Who is doing this task, for a ledger that shows the lanes — said per attempt.
|
|
443
|
-
say('task.model', { taskId: task.id, role: role.id, model: m.model, attempt });
|
|
448
|
+
say('task.model', { taskId: task.id, role: role.id, model: m.model, label: m.label || null, attempt });
|
|
444
449
|
routed = routeOf(m, role, { attempt, exclude, handoff: handoffNow });
|
|
445
450
|
say('task.routed', { taskId: task.id, role: role.id, attempt, ...routed });
|
|
446
|
-
|
|
451
|
+
// The label beside the id: a work log that reads `mqk41ucyhmz1au → mqqzh4970js34c` names
|
|
452
|
+
// nothing to the person reading it.
|
|
453
|
+
attempts.push({ model: m.model, label: m.label || routed.engine?.label || routed.engine?.model || null, engine: routed.engine, at: now(), continued: !!note });
|
|
447
454
|
attemptNo = attempts.length;
|
|
448
455
|
const sent = messagesFor({ transcript }, { prompt, note });
|
|
449
456
|
// The record grows AS THE ATTEMPT GOES: a host that reports each wire message the
|
|
@@ -525,7 +532,7 @@ export async function runTeam({
|
|
|
525
532
|
// the board sees "researcher failed: network error" where it happened, and what was tried.
|
|
526
533
|
if (thread && status === 'ok') board.setThreadStatus(thread.id, 'resolved');
|
|
527
534
|
else if (thread && status !== 'waiting') {
|
|
528
|
-
board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `${role?.id || task.role} ${status === 'over-budget' ? 'stopped at the budget' : 'failed'}${error ? `: ${String(error).slice(0, 300)}` : ''}${attempts.length > 1 ? ` (after ${attempts.length} models: ${attempts.map((a) => a.model).join(', ')})` : ''}.` });
|
|
535
|
+
board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `${role?.id || task.role} ${status === 'over-budget' ? 'stopped at the budget' : 'failed'}${error ? `: ${String(error).slice(0, 300)}` : ''}${attempts.length > 1 ? ` (after ${attempts.length} models: ${attempts.map((a) => a.label || a.model).join(', ')})` : ''}.` });
|
|
529
536
|
board.setThreadStatus(thread.id, 'failed');
|
|
530
537
|
}
|
|
531
538
|
const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, transcript: clipTranscript(transcript), attempts, ...(task.parent ? { parent: task.parent } : {}), ...(routed ? { routed } : {}), ...(scm ? { scm } : {}), ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
|
package/team-worklog.js
CHANGED
|
@@ -49,7 +49,7 @@ export function workLogFor(run, taskId) {
|
|
|
49
49
|
const out = [];
|
|
50
50
|
const attempts = Array.isArray(task.attempts) ? task.attempts : [];
|
|
51
51
|
const baseAt = num(task.startedAt, num(attempts[0]?.at, num(run?.startedAt, 0)));
|
|
52
|
-
attempts.forEach((a, i) => out.push({ id: `attempt:${i + 1}`, kind: 'attempt', at: num(a.at, baseAt + i), by: 'runner', attempt: i + 1, model: a.model || '', engine: a.engine || null, continued: !!a.continued, status: a.status || null, error: a.error || null }));
|
|
52
|
+
attempts.forEach((a, i) => out.push({ id: `attempt:${i + 1}`, kind: 'attempt', at: num(a.at, baseAt + i), by: 'runner', attempt: i + 1, model: a.label || a.engine?.label || a.engine?.model || a.model || '', modelId: a.model || '', engine: a.engine || null, continued: !!a.continued, status: a.status || null, error: a.error || null }));
|
|
53
53
|
|
|
54
54
|
// Steps: stamped ones sort by their time; unstamped ones follow their attempt in order.
|
|
55
55
|
const steps = Array.isArray(task.transcript) ? task.transcript : [];
|
|
@@ -101,7 +101,7 @@ export function workLogFor(run, taskId) {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
function endText(task, attempts) {
|
|
104
|
-
const tried = attempts.length > 1 ? ` after ${attempts.length} models (${attempts.map((a) => a.model).join(' → ')})` : '';
|
|
104
|
+
const tried = attempts.length > 1 ? ` after ${attempts.length} models (${attempts.map((a) => a.label || a.engine?.label || a.engine?.model || a.model).join(' → ')})` : '';
|
|
105
105
|
const s = task.status;
|
|
106
106
|
if (s === 'ok') return `done${tried} · ${task.findings || 0} finding${task.findings === 1 ? '' : 's'} · ${task.tools || 0} tool call${task.tools === 1 ? '' : 's'}`;
|
|
107
107
|
if (s === 'waiting') return 'waiting on a person';
|
package/turn-loop.js
CHANGED
|
@@ -412,7 +412,8 @@ export async function runTurnLoop({
|
|
|
412
412
|
// Reconcile: an adapter that returned text without streaming it still gets it into `said`.
|
|
413
413
|
const text = String(res?.text || '');
|
|
414
414
|
if (text && !roundText) { if (said) said += ROUND_SEPARATOR; said += text; roundText = text; }
|
|
415
|
-
|
|
415
|
+
// `status` rides along when the adapter had one — a failover classifier reads it.
|
|
416
|
+
if (!res?.ok) { finish('error'); return result({ ok: false, error: res?.error || 'the model did not answer', ...(res?.status ? { status: res.status } : {}), aborted: !!res?.aborted, transcript: text.trim() ? [...convo, transcript.said(said)] : convo }); }
|
|
416
417
|
if (res.aborted || signal?.aborted) { finish('aborted'); return closeWith(said, { aborted: true }); }
|
|
417
418
|
|
|
418
419
|
const wanted = (Array.isArray(res.toolCalls) ? res.toolCalls : []).filter((c) => c && c.name).map((c) => ({ id: c.id, name: c.name, input: c.input ?? safeJson(c.arguments), arguments: c.arguments }));
|