@chatpanel/events 0.94.0 → 0.96.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/client-prefs.js +1 -1
- package/failover.js +93 -0
- package/index.js +16 -1
- package/model-health.js +182 -0
- package/package.json +9 -1
- package/tool-hints.js +7 -1
- package/tool-loop-guard.js +184 -0
- package/tool-traits.js +27 -3
- package/turn-loop.js +442 -0
package/client-prefs.js
CHANGED
|
@@ -33,7 +33,7 @@ export const PREF_SECTIONS = Object.freeze([
|
|
|
33
33
|
// fold on the gateway's project record, not here.
|
|
34
34
|
{ id: 'projects', label: 'Projects', path: ['projects'], kind: 'array' },
|
|
35
35
|
{ id: 'webSearch', label: 'Web search', path: ['ui', 'webSearch'], kind: 'object' },
|
|
36
|
-
{ id: 'tools', label: 'Tools', path: null, kind: 'object', keys: ['mcpToolsMode', 'maxToolsPerTurn', 'historyTools', 'historyContextMode', 'dataDispatch', 'toolResultMaxChars'] },
|
|
36
|
+
{ id: 'tools', label: 'Tools', path: null, kind: 'object', keys: ['mcpToolsMode', 'maxToolsPerTurn', 'maxToolRoundsPerTurn', 'historyTools', 'historyContextMode', 'dataDispatch', 'toolResultMaxChars'] },
|
|
37
37
|
{ id: 'suggestions', label: 'Smart suggestions', path: ['ui', 'suggestions'], kind: 'object' },
|
|
38
38
|
{ id: 'topics', label: 'Topic extraction', path: ['ui', 'topicExtraction'], kind: 'object' },
|
|
39
39
|
{ id: 'voice', label: 'Voice', path: ['ui', 'voice'], kind: 'object' },
|
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
|
@@ -170,8 +170,23 @@ export {
|
|
|
170
170
|
export { explainMcpError, packageFromArgs, isStaleMcpSession } from './mcp-errors.js';
|
|
171
171
|
// The tool round — what a tool does, how a round runs, what a result costs, how a tool is
|
|
172
172
|
// found, and a workflow written down once (see docs/ROADMAP "the tool round" in chatpanel).
|
|
173
|
-
export { toolTraits, bareToolName, canRunConcurrently, isCacheable, needsConfirmation, traitsIndex } from './tool-traits.js';
|
|
173
|
+
export { toolTraits, bareToolName, canRunConcurrently, isCacheable, needsConfirmation, traitsIndex, effectiveToolName, parallelEligible, PARALLEL_LOCAL_RE } from './tool-traits.js';
|
|
174
174
|
export { planToolRound, runToolRound } from './tool-round.js';
|
|
175
|
+
// The turn loop — the one loop every client runs (rounds, guard, cap, exhaustion, usage),
|
|
176
|
+
// with the provider call, the tools and the transcript shape injected.
|
|
177
|
+
export {
|
|
178
|
+
createToolLoopGuard, roundSignature, stableToolCallKey, toolMadeProgress, isLoopableTool, blockedToolResult,
|
|
179
|
+
OBSERVATION_TOOLS, INPUT_PROGRESS_TOOLS,
|
|
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
|
+
export {
|
|
186
|
+
runTurnLoop, createCallRunner, roundCap, withToolSystem, describeCall as describeToolCall, stepResultText, addUsage, normalizeUsage,
|
|
187
|
+
openAiTranscript, anthropicTranscript,
|
|
188
|
+
DEFAULT_MAX_ROUNDS, DEFAULT_MAX_FINISH_TRIES, FINISH_NUDGES, LOOPING_NUDGE, EXHAUSTED_NOTE, ROUND_SEPARATOR,
|
|
189
|
+
} from './turn-loop.js';
|
|
175
190
|
export {
|
|
176
191
|
createResultStore, shieldToolResult, runResultQuery, withResultShield, describeShape, compactValue,
|
|
177
192
|
resultToolSpec, RESULT_TOOL_NAME, DEFAULT_SHIELD, DEFAULT_STORE,
|
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.96.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",
|
|
@@ -120,11 +122,13 @@
|
|
|
120
122
|
"./tool-hints.js": "./tool-hints.js",
|
|
121
123
|
"./tool-need.js": "./tool-need.js",
|
|
122
124
|
"./tool-result.js": "./tool-result.js",
|
|
125
|
+
"./tool-loop-guard.js": "./tool-loop-guard.js",
|
|
123
126
|
"./tool-round.js": "./tool-round.js",
|
|
124
127
|
"./tool-schema.js": "./tool-schema.js",
|
|
125
128
|
"./tool-traits.js": "./tool-traits.js",
|
|
126
129
|
"./toolset.js": "./toolset.js",
|
|
127
130
|
"./trajectory.js": "./trajectory.js",
|
|
131
|
+
"./turn-loop.js": "./turn-loop.js",
|
|
128
132
|
"./upcast.js": "./upcast.js",
|
|
129
133
|
"./vault.js": "./vault.js",
|
|
130
134
|
"./view.js": "./view.js",
|
|
@@ -159,6 +163,7 @@
|
|
|
159
163
|
"entity.js",
|
|
160
164
|
"event.js",
|
|
161
165
|
"extraction.js",
|
|
166
|
+
"failover.js",
|
|
162
167
|
"find-tool.js",
|
|
163
168
|
"flowchart.js",
|
|
164
169
|
"harness.js",
|
|
@@ -183,6 +188,7 @@
|
|
|
183
188
|
"meeting-text.js",
|
|
184
189
|
"memory.js",
|
|
185
190
|
"model-candidates.js",
|
|
191
|
+
"model-health.js",
|
|
186
192
|
"model-ledger.js",
|
|
187
193
|
"model-picker.js",
|
|
188
194
|
"note-actions.js",
|
|
@@ -254,11 +260,13 @@
|
|
|
254
260
|
"tool-hints.js",
|
|
255
261
|
"tool-need.js",
|
|
256
262
|
"tool-result.js",
|
|
263
|
+
"tool-loop-guard.js",
|
|
257
264
|
"tool-round.js",
|
|
258
265
|
"tool-schema.js",
|
|
259
266
|
"tool-traits.js",
|
|
260
267
|
"toolset.js",
|
|
261
268
|
"trajectory.js",
|
|
269
|
+
"turn-loop.js",
|
|
262
270
|
"upcast.js",
|
|
263
271
|
"vault.js",
|
|
264
272
|
"view.js",
|
package/tool-hints.js
CHANGED
|
@@ -24,7 +24,13 @@ export function sourceCitationSystem({ compact = false } = {}) {
|
|
|
24
24
|
|
|
25
25
|
export function toolStatus(result) {
|
|
26
26
|
const o = resultObject(result);
|
|
27
|
-
if (!o)
|
|
27
|
+
if (!o) {
|
|
28
|
+
// A plain-text result (a search's prose, a relayed agent's line): an error when it says
|
|
29
|
+
// so the way the shared tools do — `error: …`, `web_search failed: …` — else fine.
|
|
30
|
+
const s = typeof result === 'string' ? result : (result && typeof result === 'object' && typeof result.text === 'string' ? result.text : '');
|
|
31
|
+
if (!s.trim()) return '';
|
|
32
|
+
return /^(error:|\w+ failed\b)/i.test(s) ? `error: ${s.slice(0, 80)}` : 'ok';
|
|
33
|
+
}
|
|
28
34
|
if (o.error) {
|
|
29
35
|
const detail = errorDetail(o);
|
|
30
36
|
if (o.blocked) return `blocked: ${detail}`.slice(0, 90);
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The loop guard — what stops a model that keeps asking the same thing.
|
|
2
|
+
//
|
|
3
|
+
// Lived inside the extension's providers.js for a year, which meant the desktop could not
|
|
4
|
+
// use it and wrote a smaller one (a `seen` map and a repeat count) that answered the same
|
|
5
|
+
// model behaviour differently: the extension blocked a repeated write BEFORE running it,
|
|
6
|
+
// the desktop ran it once and replayed it; the extension noticed a whole round repeating,
|
|
7
|
+
// the desktop only a single call. Same tools, same model, two outcomes. Now one guard,
|
|
8
|
+
// with everything either client had learned:
|
|
9
|
+
//
|
|
10
|
+
// • a call repeated past `maxIdenticalCalls` is not executed — a READ is answered from
|
|
11
|
+
// its first result (a pure read asked twice has one answer, and refusing it is how a
|
|
12
|
+
// small model concludes the tool is broken and invents an answer); a WRITE is refused
|
|
13
|
+
// with a result that says why, because replaying a click would be a lie about
|
|
14
|
+
// something that changed the world;
|
|
15
|
+
// • observation tools never count — read → act → read again with the same empty input
|
|
16
|
+
// is correct, not a loop;
|
|
17
|
+
// • a discrete-input tool that SUCCEEDED (a keystroke, a click) is progress and clears
|
|
18
|
+
// its own count — pressing Enter twice is normal; failing to press it twice is not;
|
|
19
|
+
// • a ROUND that repeats the previous round byte for byte, or in which every call was
|
|
20
|
+
// blocked, counts toward `stalled` — and a stalled turn is offered no more tools, so
|
|
21
|
+
// the model has to answer with what it has;
|
|
22
|
+
// • `repeats` counts every replay and refusal across the turn, so a loop can tell when
|
|
23
|
+
// the model has been told enough times (the desktop's rule: three, then a closing
|
|
24
|
+
// request with no tools).
|
|
25
|
+
//
|
|
26
|
+
// Class R: no I/O, no clock. Names are read through `effectiveToolName` so a dispatched
|
|
27
|
+
// action is judged on what it is, not on the dispatcher's name.
|
|
28
|
+
|
|
29
|
+
import { effectiveToolName } from './tool-traits.js';
|
|
30
|
+
import { resultText } from './adaptive-tool-policy.js';
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_MAX_IDENTICAL_CALLS = 3;
|
|
33
|
+
export const DEFAULT_MAX_STALLED_ROUNDS = 2;
|
|
34
|
+
export const DEFAULT_MAX_REPEATS = 3;
|
|
35
|
+
|
|
36
|
+
// Observation/read tools are MEANT to be repeated with the SAME (empty) input.
|
|
37
|
+
export const OBSERVATION_TOOLS = new Set(['inspect_page', 'read_canvas', 'screenshot', 'marked_screenshot']);
|
|
38
|
+
|
|
39
|
+
// Tools whose whole job is ONE discrete physical input. A SUCCESSFUL application counts as
|
|
40
|
+
// progress and clears the repeat count; a failing one (unknown key, nothing at point) does
|
|
41
|
+
// not, so a genuinely stuck call still trips the guard.
|
|
42
|
+
export const INPUT_PROGRESS_TOOLS = new Set([
|
|
43
|
+
'press_key', 'type_text', 'click_at', 'move_mouse', 'click_mark', 'draw_path', 'input_sequence',
|
|
44
|
+
'click_element', 'click_by_text',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** A tool whose repetition signals a LOOP (search/query/fetch), not one meant to repeat. */
|
|
48
|
+
export function isLoopableTool(name) {
|
|
49
|
+
return !OBSERVATION_TOOLS.has(name) && !INPUT_PROGRESS_TOOLS.has(name) && name !== 'scroll';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stableStringify(value) {
|
|
53
|
+
if (value == null || typeof value !== 'object') return JSON.stringify(value);
|
|
54
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
55
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The identity of a call: its name and its arguments with keys in a stable order. */
|
|
59
|
+
export function stableToolCallKey(name, input) {
|
|
60
|
+
return `${String(name || '')}\n${stableStringify(input ?? {})}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The identity of a ROUND: its loopable calls, sorted — so a re-read or a scroll never makes two rounds look alike. */
|
|
64
|
+
export function roundSignature(calls) {
|
|
65
|
+
return (Array.isArray(calls) ? calls : [])
|
|
66
|
+
.filter((c) => isLoopableTool(effectiveToolName(c?.name, c?.input)))
|
|
67
|
+
.map((c) => stableToolCallKey(c.name, c.input))
|
|
68
|
+
.sort()
|
|
69
|
+
.join('|');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The result a refused repeat receives — machine-readable, with the way out spelled out. */
|
|
73
|
+
export function blockedToolResult(name, message, extra = {}) {
|
|
74
|
+
return JSON.stringify({
|
|
75
|
+
ok: false,
|
|
76
|
+
blocked: true,
|
|
77
|
+
error: 'tool_loop_blocked',
|
|
78
|
+
tool: name || 'tool',
|
|
79
|
+
message,
|
|
80
|
+
retry_hint: 'Answer using the already available conversation context and tool results. Do not call more tools unless the user asks you to continue.',
|
|
81
|
+
...extra,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Did a repeatable tool actually do something? For `scroll`, "more page below"; for a
|
|
87
|
+
* discrete input, `ok: true`. Anything else is not progress — the step cap is the backstop.
|
|
88
|
+
*/
|
|
89
|
+
export function toolMadeProgress(name, result, input = null) {
|
|
90
|
+
// Through the dispatcher too: `page {action:'scroll'}` is a scroll. Judged by the bare
|
|
91
|
+
// name, four scrolls through `page` looked like a stuck loop and were blocked.
|
|
92
|
+
const eff = effectiveToolName(name, input);
|
|
93
|
+
if (eff === 'scroll') {
|
|
94
|
+
try { return JSON.parse(resultText(result))?.atBottom === false; } catch { return false; }
|
|
95
|
+
}
|
|
96
|
+
if (INPUT_PROGRESS_TOOLS.has(eff)) {
|
|
97
|
+
try { return JSON.parse(resultText(result))?.ok === true; } catch { return false; }
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param maxIdenticalCalls how many times the SAME call runs before it is replayed or refused
|
|
104
|
+
* @param maxStalledRounds how many no-progress rounds in a row before `stalled`
|
|
105
|
+
* @param maxRepeats how many replays/refusals in a turn before `looping`
|
|
106
|
+
*/
|
|
107
|
+
export function createToolLoopGuard({
|
|
108
|
+
maxIdenticalCalls = DEFAULT_MAX_IDENTICAL_CALLS,
|
|
109
|
+
maxStalledRounds = DEFAULT_MAX_STALLED_ROUNDS,
|
|
110
|
+
maxRepeats = DEFAULT_MAX_REPEATS,
|
|
111
|
+
} = {}) {
|
|
112
|
+
const counts = new Map();
|
|
113
|
+
const lastResult = new Map(); // key → what that identical call returned the first time
|
|
114
|
+
let stalledRounds = 0;
|
|
115
|
+
let lastSignature = null;
|
|
116
|
+
let repeats = 0;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
// No nuclear per-turn kill switch — one looping tool must not disable the rest. The
|
|
120
|
+
// round cap is the overall backstop.
|
|
121
|
+
get disabled() { return false; },
|
|
122
|
+
get stalled() { return stalledRounds >= maxStalledRounds; },
|
|
123
|
+
/** Replays and refusals so far this turn. */
|
|
124
|
+
get repeats() { return repeats; },
|
|
125
|
+
/** The model has been answered "you already asked that" enough times to stop asking. */
|
|
126
|
+
get looping() { return repeats >= maxRepeats; },
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* After each round, note progress. No progress = every call was blocked, OR the round's
|
|
130
|
+
* loopable call-set is byte-identical to the previous round's (a loop even before the
|
|
131
|
+
* per-tool threshold trips). An exact-repeat round is definitive — two strikes at once.
|
|
132
|
+
*/
|
|
133
|
+
noteRound(blockedCount, total, signature = '') {
|
|
134
|
+
const allBlocked = total > 0 && blockedCount >= total;
|
|
135
|
+
const repeatRound = !!signature && signature === lastSignature;
|
|
136
|
+
lastSignature = signature;
|
|
137
|
+
if (repeatRound) stalledRounds += 2;
|
|
138
|
+
else if (allBlocked) stalledRounds += 1;
|
|
139
|
+
else stalledRounds = 0;
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
/** Clear a call's repeat count when it actually made progress. */
|
|
143
|
+
reset(key) { if (key) counts.delete(key); },
|
|
144
|
+
|
|
145
|
+
/** Remember what a READ returned, so a repeat can be answered instead of refused. */
|
|
146
|
+
remember(key, name, input, result, { readOnly = false } = {}) {
|
|
147
|
+
if (!key || !result || !readOnly) return;
|
|
148
|
+
lastResult.set(key, result);
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Should this call run? `{ blocked, replayed, count, key, result }` — `result` is what to
|
|
153
|
+
* answer with when it should not.
|
|
154
|
+
*/
|
|
155
|
+
check(name, input) {
|
|
156
|
+
if (OBSERVATION_TOOLS.has(effectiveToolName(name, input))) return { blocked: false };
|
|
157
|
+
const key = stableToolCallKey(name, input);
|
|
158
|
+
const count = (counts.get(key) || 0) + 1;
|
|
159
|
+
counts.set(key, count);
|
|
160
|
+
if (count > maxIdenticalCalls && lastResult.has(key)) {
|
|
161
|
+
// Serve the answer it already earned — and say so, because a model that repeats
|
|
162
|
+
// itself is usually waiting for a value that will not change. Still counted, so a
|
|
163
|
+
// genuinely stuck loop stays visible in the log.
|
|
164
|
+
repeats += 1;
|
|
165
|
+
const prior = lastResult.get(key);
|
|
166
|
+
const note = '[This exact call was already made this turn; the result is unchanged. Answer from what you have.]';
|
|
167
|
+
const result = typeof prior === 'string' ? `${prior}\n\n${note}` : (prior && typeof prior === 'object' && typeof prior.text === 'string' ? { ...prior, text: `${prior.text}\n\n${note}` } : prior);
|
|
168
|
+
return { blocked: false, replayed: true, count, key, result };
|
|
169
|
+
}
|
|
170
|
+
if (count > maxIdenticalCalls) {
|
|
171
|
+
repeats += 1;
|
|
172
|
+
return {
|
|
173
|
+
blocked: true, count, key,
|
|
174
|
+
result: blockedToolResult(
|
|
175
|
+
name,
|
|
176
|
+
`Skipped a repeated identical ${name || 'tool'} call (${count}× with the same input). Vary the input or try a different action — your other tools still work.`,
|
|
177
|
+
{ repeated: true, identicalCallCount: count, maxIdenticalCalls },
|
|
178
|
+
),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { blocked: false, count, key };
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
package/tool-traits.js
CHANGED
|
@@ -150,9 +150,33 @@ export function withDestructiveGate(toolset, { confirm = null, only = () => true
|
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
// A dispatcher carries the real action in `input.action`;
|
|
154
|
-
// `mcp {action:"mcp_x__delete_repo"}` is judged by the name "mcp"
|
|
155
|
-
|
|
153
|
+
// A dispatcher carries the real action in `input.action`; every name-based policy must
|
|
154
|
+
// see through it or `mcp {action:"mcp_x__delete_repo"}` is judged by the name "mcp" — and
|
|
155
|
+
// `page {action:'screenshot'}` taken four times looks like a stuck loop instead of a look.
|
|
156
|
+
// One definition: the extension, the desktop and the gate each had their own copy.
|
|
157
|
+
export function effectiveToolName(name, input) {
|
|
156
158
|
const action = input && typeof input === 'object' ? input.action : null;
|
|
157
159
|
return typeof action === 'string' && action ? action : name;
|
|
158
160
|
}
|
|
161
|
+
const defaultEffectiveName = effectiveToolName;
|
|
162
|
+
|
|
163
|
+
// Local tools whose reads may overlap in one round: they touch the user's own data or the
|
|
164
|
+
// network, never the one tab a page tool is driving. Everything not remote and not here
|
|
165
|
+
// runs one at a time, whatever its name says — a wrong "parallel" races the world, a wrong
|
|
166
|
+
// "serial" only costs latency. The `find` dispatcher is here as a whole: everything behind
|
|
167
|
+
// it is a read of the user's data or the web (its writes are separate tools by design).
|
|
168
|
+
//
|
|
169
|
+
// ONE list. The extension and the desktop each kept their own and they drifted within
|
|
170
|
+
// weeks — the desktop serialised `recall` and `skill_open` that the extension overlapped.
|
|
171
|
+
export const PARALLEL_LOCAL_RE = /^(find$|history_|web_search$|weather$|get_result$|skill_open$|skill_file$|recall$|memory_recall$|meeting_live_transcript$)/;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* May this call share a batch with its neighbours? Read-only by its traits, not pinned
|
|
175
|
+
* serial by the toolset, and either remote (its own server) or on the local overlap list.
|
|
176
|
+
*/
|
|
177
|
+
export function parallelEligible(tools, call, traits) {
|
|
178
|
+
if (!traits?.readOnly) return false;
|
|
179
|
+
if (tools?.serialTools?.has(call.name)) return false;
|
|
180
|
+
const eff = effectiveToolName(call.name, call.input);
|
|
181
|
+
return !!tools?.remoteTools?.has(call.name) || PARALLEL_LOCAL_RE.test(eff) || PARALLEL_LOCAL_RE.test(call.name);
|
|
182
|
+
}
|
package/turn-loop.js
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// The turn loop — a model that asks for tools gets them run, and is asked again.
|
|
2
|
+
//
|
|
3
|
+
// One request either ends with an answer or with tool calls. On the second, the calls are
|
|
4
|
+
// run here and their results go back as the next request, until the model answers in words,
|
|
5
|
+
// the round cap is reached, or the guard decides the model is going in circles. That loop
|
|
6
|
+
// was written three times — once per provider in the extension, once in the desktop — and
|
|
7
|
+
// each copy knew something the others did not: the extension withheld tools on the last
|
|
8
|
+
// round and noticed a round repeating itself; the desktop kept a transcript, survived an
|
|
9
|
+
// abort with the words so far, and nudged a relayed CLI agent that ignores "no tools" until
|
|
10
|
+
// it answered. A fix to one never reached the other two. Now there is one loop and three
|
|
11
|
+
// bindings, each about thirty lines.
|
|
12
|
+
//
|
|
13
|
+
// What is injected, because it is the host's:
|
|
14
|
+
// • `stream(req)` — ONE model request: the provider call, its SSE decoding, its auth.
|
|
15
|
+
// Returns `{ ok, text, toolCalls, usage, aborted, error, finish,
|
|
16
|
+
// blocks?, noVision? }`. A thrown error propagates untouched — the
|
|
17
|
+
// extension's failover reads it.
|
|
18
|
+
// • `tools.execute` — what a call does. The toolset also carries `specs`, `traits`,
|
|
19
|
+
// `remoteTools`, `serialTools`.
|
|
20
|
+
// • `transcript` — how the asked/answered pair is written in this provider's wire
|
|
21
|
+
// shape. OpenAI (also what the gateway relays) and Anthropic ship
|
|
22
|
+
// here; a host with a third shape brings its own.
|
|
23
|
+
// • the callbacks — deltas, activity, steps, wire messages.
|
|
24
|
+
//
|
|
25
|
+
// What is NOT injected, because it is the point: the guard, the round, the cap, the
|
|
26
|
+
// exhaustion, the accounting. Class R with an async seam: no I/O of its own, no clock.
|
|
27
|
+
|
|
28
|
+
import { runToolRound } from './tool-round.js';
|
|
29
|
+
import { toolTraits, effectiveToolName, parallelEligible } from './tool-traits.js';
|
|
30
|
+
import { createToolLoopGuard, roundSignature, toolMadeProgress, blockedToolResult } from './tool-loop-guard.js';
|
|
31
|
+
import { createAdaptiveToolPolicy, resultText } from './adaptive-tool-policy.js';
|
|
32
|
+
import { toolStatus } from './tool-hints.js';
|
|
33
|
+
|
|
34
|
+
/** Model requests one turn may make when tools are armed. Configurable, 60 is the ceiling either client ran with. */
|
|
35
|
+
export const DEFAULT_MAX_ROUNDS = 60;
|
|
36
|
+
/** How many stray tool calls a closing request (no tools offered) is answered before the turn ends anyway. */
|
|
37
|
+
export const DEFAULT_MAX_FINISH_TRIES = 6;
|
|
38
|
+
/** What a step shows of a result — the model receives the whole thing. */
|
|
39
|
+
export const STEP_RESULT_MAX_CHARS = 4000;
|
|
40
|
+
/** Rounds of one turn are separated in the text the user reads. */
|
|
41
|
+
export const ROUND_SEPARATOR = '\n\n';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A relayed CLI agent keeps its OWN session and its own tools, so "no tools offered" does
|
|
45
|
+
* not stop it asking — it answered the closing request with another call and an empty text,
|
|
46
|
+
* and a member's whole answer was its opening sentence. Each such call is answered with the
|
|
47
|
+
* next of these until words come back.
|
|
48
|
+
*/
|
|
49
|
+
export const FINISH_NUDGES = Object.freeze([
|
|
50
|
+
'The tool budget for this turn is spent. Do not call tools again; write your answer now with what you have, including your findings.',
|
|
51
|
+
'No more tool calls will be answered. Reply with your answer as plain text, now — a partial answer beats none.',
|
|
52
|
+
'FINAL: any further tool call ends this turn with no answer. Write what you have found, as text, in this message.',
|
|
53
|
+
]);
|
|
54
|
+
export const LOOPING_NUDGE = 'You have repeated the same tool call several times. Do not call tools again; answer now with what you have.';
|
|
55
|
+
/** Appended by a client that renders the exhausted flag as words — kept here so both say the same thing. */
|
|
56
|
+
export const EXHAUSTED_NOTE = '_(Reached the action limit for one turn — say "continue" to keep going.)_';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* How many rounds this turn may take. The agent's own setting wins, then the user's
|
|
60
|
+
* preference, then the default; a turn without tools is one request.
|
|
61
|
+
*/
|
|
62
|
+
export function roundCap({ tools, agent, settings, fallback = DEFAULT_MAX_ROUNDS } = {}) {
|
|
63
|
+
if (!tools) return 1;
|
|
64
|
+
const ceiling = Math.max(1, Number(fallback) || DEFAULT_MAX_ROUNDS);
|
|
65
|
+
const own = Number(agent?.maxRequestsPerTurn) || 0;
|
|
66
|
+
if (own > 0) return Math.min(ceiling, own);
|
|
67
|
+
const pref = Number(settings?.ui?.maxToolRoundsPerTurn ?? settings?.maxToolRoundsPerTurn) || 0;
|
|
68
|
+
if (pref > 0) return Math.min(ceiling, pref);
|
|
69
|
+
return ceiling;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function safeJson(s) {
|
|
73
|
+
if (!s) return {};
|
|
74
|
+
if (typeof s === 'object') return s;
|
|
75
|
+
try { return JSON.parse(s); } catch { return {}; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const argString = (c) => (typeof c.arguments === 'string' ? c.arguments : JSON.stringify(c.arguments ?? c.input ?? {}));
|
|
79
|
+
|
|
80
|
+
/** What the model reads back from a tool: the `string | { text }` executor contract. */
|
|
81
|
+
export { resultText };
|
|
82
|
+
|
|
83
|
+
/** A short, display-safe slice of a result for a step — the model still gets the full result. */
|
|
84
|
+
export function stepResultText(result) {
|
|
85
|
+
const s = String(resultText(result) || '');
|
|
86
|
+
return s.length > STEP_RESULT_MAX_CHARS ? `${s.slice(0, STEP_RESULT_MAX_CHARS)}…` : s;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One line for the activity trail — the real action and the argument that identifies it. */
|
|
90
|
+
export function describeCall(name, input) {
|
|
91
|
+
const eff = effectiveToolName(name, input);
|
|
92
|
+
const args = input && typeof input === 'object' && input.args && typeof input.args === 'object' ? input.args : (input || {});
|
|
93
|
+
const key = ['query', 'id', 'tool', 'location', 'ref'].find((k) => typeof args[k] === 'string' && args[k]);
|
|
94
|
+
return key ? `${eff} "${String(args[key]).slice(0, 80)}"` : eff;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Put the toolset's guidance in front of the model, merged into an existing system turn. */
|
|
98
|
+
export function withToolSystem(messages, system) {
|
|
99
|
+
const list = Array.isArray(messages) ? [...messages] : [];
|
|
100
|
+
const text = String(system || '').trim();
|
|
101
|
+
if (!text) return list;
|
|
102
|
+
const i = list.findIndex((m) => m?.role === 'system' && typeof m.content === 'string');
|
|
103
|
+
if (i >= 0) { list[i] = { ...list[i], content: `${list[i].content}\n\n${text}` }; return list; }
|
|
104
|
+
return [{ role: 'system', content: text }, ...list];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------------------
|
|
108
|
+
// Usage — adds up across rounds, in both key styles, so a team budget reading
|
|
109
|
+
// `prompt_tokens` and a ledger reading `inputTokens` see the same turn.
|
|
110
|
+
// ---------------------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
const n = (v) => Number(v) || 0;
|
|
113
|
+
|
|
114
|
+
export function normalizeUsage(u) {
|
|
115
|
+
if (!u || typeof u !== 'object') return null;
|
|
116
|
+
const inputTokens = n(u.inputTokens ?? u.input_tokens ?? u.prompt_tokens);
|
|
117
|
+
const outputTokens = n(u.outputTokens ?? u.output_tokens ?? u.completion_tokens);
|
|
118
|
+
const cacheReadTokens = n(u.cacheReadTokens ?? u.cache_read_input_tokens ?? u.prompt_tokens_details?.cached_tokens);
|
|
119
|
+
const cacheWriteTokens = n(u.cacheWriteTokens ?? u.cache_creation_input_tokens);
|
|
120
|
+
const out = {
|
|
121
|
+
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
|
|
122
|
+
prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: n(u.total_tokens) || inputTokens + outputTokens,
|
|
123
|
+
calls: n(u.calls) || 1,
|
|
124
|
+
reported: u.reported !== false && (inputTokens > 0 || outputTokens > 0),
|
|
125
|
+
};
|
|
126
|
+
const usd = n(u.usd ?? u.cost);
|
|
127
|
+
if (usd) out.usd = usd;
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Two usage records as one. Either may be in a provider's raw shape. */
|
|
132
|
+
export function addUsage(a, b) {
|
|
133
|
+
const A = normalizeUsage(a); const B = normalizeUsage(b);
|
|
134
|
+
if (!B) return A;
|
|
135
|
+
if (!A) return B;
|
|
136
|
+
const out = {
|
|
137
|
+
inputTokens: A.inputTokens + B.inputTokens, outputTokens: A.outputTokens + B.outputTokens,
|
|
138
|
+
cacheReadTokens: A.cacheReadTokens + B.cacheReadTokens, cacheWriteTokens: A.cacheWriteTokens + B.cacheWriteTokens,
|
|
139
|
+
calls: A.calls + B.calls, reported: A.reported || B.reported,
|
|
140
|
+
};
|
|
141
|
+
out.prompt_tokens = out.inputTokens; out.completion_tokens = out.outputTokens; out.total_tokens = A.total_tokens + B.total_tokens;
|
|
142
|
+
const usd = n(A.usd) + n(B.usd);
|
|
143
|
+
if (usd) out.usd = usd;
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** ~4 chars/token — ONLY when a provider reported nothing. No tokenizer: real usage is accurate and free. */
|
|
148
|
+
export function estimateTokens(text) {
|
|
149
|
+
return Math.max(0, Math.round(String(text || '').length / 4));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function estimatedUsage(messages, text) {
|
|
153
|
+
const inText = (messages || []).map((m) => (typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content || ''))).join('\n');
|
|
154
|
+
return { inputTokens: estimateTokens(inText), outputTokens: estimateTokens(text), cacheReadTokens: 0, cacheWriteTokens: 0, estimated: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------------------
|
|
158
|
+
// Transcripts — the asked/answered pair in a provider's wire shape.
|
|
159
|
+
// ---------------------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/** The OpenAI chat shape: what the gateway relays and every provider behind it understands. */
|
|
162
|
+
export const openAiTranscript = Object.freeze({
|
|
163
|
+
asked(res, calls) {
|
|
164
|
+
return {
|
|
165
|
+
role: 'assistant',
|
|
166
|
+
content: String(res?.text || '') || null,
|
|
167
|
+
tool_calls: calls.map((c) => ({ id: c.id, type: 'function', function: { name: c.name, arguments: argString(c) } })),
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
answered(pairs, { noVision = false } = {}) {
|
|
171
|
+
const out = pairs.map(({ call, result }) => ({ role: 'tool', tool_call_id: call.id, content: resultText(result) }));
|
|
172
|
+
// A tool message cannot carry an image. A screenshot goes back as a user message AFTER
|
|
173
|
+
// the round's tool messages (a user turn between two tool turns is rejected by strict
|
|
174
|
+
// providers), or as a note once the model has said it has no vision.
|
|
175
|
+
for (const { call, result } of pairs) {
|
|
176
|
+
const image = result && typeof result === 'object' ? result.image : null;
|
|
177
|
+
if (!image) continue;
|
|
178
|
+
out.push(noVision
|
|
179
|
+
? { role: 'user', content: `(Screenshot from ${call.name} omitted — this model has no vision. Rely on read_canvas / inspect_page / tool results.)` }
|
|
180
|
+
: { role: 'user', content: [{ type: 'text', text: `(Screenshot from ${call.name})` }, { type: 'image_url', image_url: { url: image } }] });
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
},
|
|
184
|
+
nudge(calls, text) {
|
|
185
|
+
return [
|
|
186
|
+
{ role: 'assistant', content: null, tool_calls: calls.map((c) => ({ id: c.id, type: 'function', function: { name: c.name, arguments: argString(c) } })) },
|
|
187
|
+
...calls.map((c) => ({ role: 'tool', tool_call_id: c.id, content: text })),
|
|
188
|
+
];
|
|
189
|
+
},
|
|
190
|
+
system: (text) => ({ role: 'system', content: text }),
|
|
191
|
+
said: (text) => ({ role: 'assistant', content: text }),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/** The Anthropic Messages shape: content blocks, every result of a round in ONE user turn. */
|
|
195
|
+
export const anthropicTranscript = Object.freeze({
|
|
196
|
+
asked(res, calls) {
|
|
197
|
+
// Echo the assistant's own blocks when the adapter kept them (text and tool_use in the
|
|
198
|
+
// order they came), dropping empty text — the API rejects zero-length text content.
|
|
199
|
+
const blocks = Array.isArray(res?.blocks) && res.blocks.length
|
|
200
|
+
? res.blocks.filter((b) => b && (b.type === 'tool_use' || (b.type === 'text' && b.text))).map((b) => (b.type === 'tool_use' ? { type: 'tool_use', id: b.id, name: b.name, input: b.input ?? safeJson(b.json) } : { type: 'text', text: b.text }))
|
|
201
|
+
: [...(res?.text ? [{ type: 'text', text: String(res.text) }] : []), ...calls.map((c) => ({ type: 'tool_use', id: c.id, name: c.name, input: c.input }))];
|
|
202
|
+
return { role: 'assistant', content: blocks };
|
|
203
|
+
},
|
|
204
|
+
answered(pairs) {
|
|
205
|
+
return [{
|
|
206
|
+
role: 'user',
|
|
207
|
+
content: pairs.map(({ call, result }) => {
|
|
208
|
+
const text = resultText(result);
|
|
209
|
+
const image = result && typeof result === 'object' ? result.image : null;
|
|
210
|
+
if (!image) return { type: 'tool_result', tool_use_id: call.id, content: text };
|
|
211
|
+
const im = /^data:([^;]+);base64,(.+)$/s.exec(image);
|
|
212
|
+
const content = [];
|
|
213
|
+
if (im) content.push({ type: 'image', source: { type: 'base64', media_type: im[1], data: im[2] } });
|
|
214
|
+
content.push({ type: 'text', text });
|
|
215
|
+
return { type: 'tool_result', tool_use_id: call.id, content };
|
|
216
|
+
}),
|
|
217
|
+
}];
|
|
218
|
+
},
|
|
219
|
+
nudge(calls, text) {
|
|
220
|
+
return [
|
|
221
|
+
{ role: 'assistant', content: calls.map((c) => ({ type: 'tool_use', id: c.id, name: c.name, input: c.input })) },
|
|
222
|
+
{ role: 'user', content: calls.map((c) => ({ type: 'tool_result', tool_use_id: c.id, content: text })) },
|
|
223
|
+
];
|
|
224
|
+
},
|
|
225
|
+
// No system role in the message list — the instruction rides as the user's words.
|
|
226
|
+
system: (text) => ({ role: 'user', content: text }),
|
|
227
|
+
said: (text) => ({ role: 'assistant', content: text }),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------------------
|
|
231
|
+
// Calls — one guarded call, one guarded round. The same code answers a call that arrives
|
|
232
|
+
// mid-stream from a CLI agent (the bridge relays one at a time) and a round of calls from
|
|
233
|
+
// an API model.
|
|
234
|
+
// ---------------------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @param tools the toolset
|
|
238
|
+
* @param guard a tool-loop guard (one per turn)
|
|
239
|
+
* @param policy an adaptive tool policy (one per turn)
|
|
240
|
+
* @param modelLabel `() => string` — WHICH model made this call, read per call: a turn can
|
|
241
|
+
* change model mid-flight (failover), and attributing every action to
|
|
242
|
+
* whichever model finished misreports the work
|
|
243
|
+
* @param maxCalls after this many calls in the turn, each further one is answered with a
|
|
244
|
+
* "budget spent" nudge instead of running — the cap for a host that has
|
|
245
|
+
* no rounds to count (a relayed CLI agent). 0 = no cap.
|
|
246
|
+
* @param onStep `(step)` — `{ phase, callId, name, action, input, text, status, result, image, model }`
|
|
247
|
+
*/
|
|
248
|
+
export function createCallRunner({ tools, guard = createToolLoopGuard(), policy = createAdaptiveToolPolicy(), modelLabel = () => null, maxCalls = 0, onStep = null, steps = [] } = {}) {
|
|
249
|
+
let made = 0;
|
|
250
|
+
const traitsOf = (c) => {
|
|
251
|
+
const eff = effectiveToolName(c.name, c.input);
|
|
252
|
+
return tools?.traits?.get(eff) || tools?.traits?.get(c.name) || toolTraits({ name: eff });
|
|
253
|
+
};
|
|
254
|
+
const stepOf = (c, phase, result) => {
|
|
255
|
+
const step = { phase, callId: c.id, name: c.name, action: effectiveToolName(c.name, c.input), input: c.input, text: describeCall(c.name, c.input), model: modelLabel() };
|
|
256
|
+
if (phase === 'done') {
|
|
257
|
+
const image = result && typeof result === 'object' ? result.image : undefined;
|
|
258
|
+
Object.assign(step, { status: toolStatus(result), result: stepResultText(result), ...(image ? { image } : {}) });
|
|
259
|
+
}
|
|
260
|
+
return step;
|
|
261
|
+
};
|
|
262
|
+
const start = (c) => { try { onStep?.(stepOf(c, 'start')); } catch { /* reporting never breaks a turn */ } };
|
|
263
|
+
const done = (c, result) => {
|
|
264
|
+
const step = stepOf(c, 'done', result);
|
|
265
|
+
steps.push(step);
|
|
266
|
+
try { onStep?.(step); } catch { /* reporting never breaks a turn */ }
|
|
267
|
+
};
|
|
268
|
+
const settle = (c, g, result) => {
|
|
269
|
+
policy.recordResult(c.name, result);
|
|
270
|
+
if (!g.blocked && !g.replayed && toolMadeProgress(c.name, result, c.input)) guard.reset(g.key);
|
|
271
|
+
// Only a read is remembered for replay — the traits decide, not a list of names.
|
|
272
|
+
if (!g.replayed) guard.remember(g.key, c.name, c.input, result, { readOnly: !!traitsOf(c)?.readOnly });
|
|
273
|
+
};
|
|
274
|
+
const spent = (c) => blockedToolResult(c.name, FINISH_NUDGES[Math.min(Math.max(0, made - maxCalls - 1), FINISH_NUDGES.length - 1)], { budget: 'spent', calls: made, maxCalls });
|
|
275
|
+
const execute = async (c, g, meta) => {
|
|
276
|
+
made += 1;
|
|
277
|
+
if (maxCalls > 0 && made > maxCalls) return spent(c);
|
|
278
|
+
if (g.blocked || g.replayed) return g.result;
|
|
279
|
+
if (typeof tools?.execute !== 'function') return JSON.stringify({ error: 'no tools armed' });
|
|
280
|
+
return tools.execute(c.name, c.input, { callId: c.id, ...(meta || {}) });
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
guard, policy, steps, traitsOf,
|
|
285
|
+
get calls() { return made; },
|
|
286
|
+
get exhausted() { return maxCalls > 0 && made >= maxCalls; },
|
|
287
|
+
|
|
288
|
+
/** One call, arriving on its own (a relayed agent). Every exit produces a result. */
|
|
289
|
+
async one(call, meta = null) {
|
|
290
|
+
const c = { id: call.id, name: call.name, input: call.input ?? safeJson(call.arguments) };
|
|
291
|
+
start(c);
|
|
292
|
+
const g = guard.check(c.name, c.input);
|
|
293
|
+
let result;
|
|
294
|
+
try { result = await execute(c, g, meta); } catch (e) { result = JSON.stringify({ error: String(e?.message || e) }); }
|
|
295
|
+
settle(c, g, result);
|
|
296
|
+
done(c, result);
|
|
297
|
+
return result;
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* One ROUND — reads overlapped, writes in the model's order, identical calls coalesced
|
|
302
|
+
* (tool-round.js). The guard is consulted up front in the model's order (its counts are
|
|
303
|
+
* order-dependent); the policy and the guard's memory are updated from each result.
|
|
304
|
+
*/
|
|
305
|
+
async round(wanted) {
|
|
306
|
+
const calls = wanted.map((c) => ({ id: c.id, name: c.name, input: c.input ?? safeJson(c.arguments) }));
|
|
307
|
+
const guards = calls.map((c) => guard.check(c.name, c.input));
|
|
308
|
+
const { results } = await runToolRound(calls, {
|
|
309
|
+
execute: (c, i) => execute(c, guards[i]),
|
|
310
|
+
traitsOf,
|
|
311
|
+
concurrent: (c, t) => parallelEligible(tools, c, t),
|
|
312
|
+
onStart: (c) => start(c),
|
|
313
|
+
onDone: (c, i, result) => done(c, result),
|
|
314
|
+
});
|
|
315
|
+
results.forEach((result, i) => settle(calls[i], guards[i], result));
|
|
316
|
+
const blocked = guards.filter((g) => g.blocked).length;
|
|
317
|
+
guard.noteRound(blocked, calls.length, roundSignature(calls));
|
|
318
|
+
return { calls, results, blocked };
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------------------
|
|
324
|
+
// The loop.
|
|
325
|
+
// ---------------------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Run a turn to completion.
|
|
329
|
+
*
|
|
330
|
+
* @param stream `(req) => { ok, text, toolCalls?, usage?, error?, aborted?, finish?, blocks?, noVision? }`
|
|
331
|
+
* — ONE request. `req` is `{ model, messages, tools, signal, redaction, run,
|
|
332
|
+
* onDelta(delta), onActivity }`; `req.tools` is the CANONICAL spec list
|
|
333
|
+
* (`{ name, description, parameters }`) or null when none are offered — the
|
|
334
|
+
* adapter shapes it for its provider.
|
|
335
|
+
* @param tools `{ specs, execute, traits?, remoteTools?, serialTools?, system? }`; absent = one plain request
|
|
336
|
+
* @param messages the wire messages as the host assembled them (system turns included)
|
|
337
|
+
* @param transcript how asked/answered are written — `openAiTranscript` (default) or `anthropicTranscript`
|
|
338
|
+
* @param maxRounds model requests with tools; see `roundCap`
|
|
339
|
+
* @param maxFinishTries stray calls answered on the closing request before giving up
|
|
340
|
+
* @param onDelta `(delta, text)` — `text` is everything said so far ACROSS rounds
|
|
341
|
+
* @param onEvent the extension's activity stream: `{type:'tool'|'finish'|'usage', …}`
|
|
342
|
+
* @param onStep the desktop's activity trail: one step per call, start and done
|
|
343
|
+
* @param onMessage `(msg)` each wire message the moment it exists — a record that grows as
|
|
344
|
+
* the turn goes, so a process that dies mid-turn leaves the work so far
|
|
345
|
+
* @param usageLabel `{ provider, model }` stamped on the usage event
|
|
346
|
+
* @returns `{ ok, text, usage, rounds, steps, transcript, exhausted, aborted, error, finish }`
|
|
347
|
+
*/
|
|
348
|
+
export async function runTurnLoop({
|
|
349
|
+
model, messages, tools, signal, redaction, run, stream,
|
|
350
|
+
transcript = openAiTranscript,
|
|
351
|
+
maxRounds = DEFAULT_MAX_ROUNDS, maxFinishTries = DEFAULT_MAX_FINISH_TRIES,
|
|
352
|
+
guard = createToolLoopGuard(), policy = createAdaptiveToolPolicy(),
|
|
353
|
+
modelLabel = () => model || null, usageLabel = null,
|
|
354
|
+
onDelta = null, onEvent = null, onStep = null, onMessage = null, onActivity = null,
|
|
355
|
+
} = {}) {
|
|
356
|
+
if (typeof stream !== 'function') throw new Error('runTurnLoop: stream required');
|
|
357
|
+
const armed = !!(tools && Array.isArray(tools.specs) && tools.specs.length);
|
|
358
|
+
const specs = armed ? tools.specs : null;
|
|
359
|
+
const cap = armed ? Math.max(1, Number(maxRounds) || DEFAULT_MAX_ROUNDS) : 1;
|
|
360
|
+
const steps = [];
|
|
361
|
+
const runner = createCallRunner({
|
|
362
|
+
tools, guard, policy, modelLabel, steps,
|
|
363
|
+
onStep: (step) => {
|
|
364
|
+
try { onStep?.(step); } catch { /* never break a turn */ }
|
|
365
|
+
try { onEvent?.({ type: 'tool', ...step }); } catch { /* never break a turn */ }
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
let convo = [...(messages || [])];
|
|
370
|
+
let said = ''; // everything the model has said so far, across rounds
|
|
371
|
+
let usage = null;
|
|
372
|
+
let rounds = 0;
|
|
373
|
+
let noVision = false;
|
|
374
|
+
let finishTries = 0;
|
|
375
|
+
let exhausted = false;
|
|
376
|
+
const push = (msgs) => { for (const m of msgs) { convo = [...convo, m]; try { onMessage?.(m); } catch { /* never break a turn */ } } };
|
|
377
|
+
const finish = (reason) => { try { onEvent?.({ type: 'finish', reason }); } catch { /* ignore */ } };
|
|
378
|
+
const usageEvent = (text) => {
|
|
379
|
+
if (!onEvent) return;
|
|
380
|
+
const u = usage && usage.reported ? { ...usage, estimated: false } : estimatedUsage(convo, text);
|
|
381
|
+
try { onEvent({ type: 'usage', provider: usageLabel?.provider || 'unknown', model: usageLabel?.model || model || null, inputTokens: u.inputTokens, outputTokens: u.outputTokens, cacheReadTokens: u.cacheReadTokens, cacheWriteTokens: u.cacheWriteTokens, estimated: !!u.estimated }); } catch { /* ignore */ }
|
|
382
|
+
};
|
|
383
|
+
const result = (over) => ({ ok: true, text: said, usage, rounds, steps, transcript: convo, exhausted, aborted: false, ...over });
|
|
384
|
+
const closeWith = (text, over = {}) => {
|
|
385
|
+
const t = String(text || '');
|
|
386
|
+
if (t.trim()) push([transcript.said(t)]);
|
|
387
|
+
return result(over);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// eslint-disable-next-line no-constant-condition
|
|
391
|
+
while (true) {
|
|
392
|
+
rounds += 1;
|
|
393
|
+
// The closing request: the cap is reached, the guard says the model is circling, or a
|
|
394
|
+
// stray call already came back to a request that offered nothing. No tools, so the turn
|
|
395
|
+
// ends with words — and if the agent asks anyway, it is answered until it stops.
|
|
396
|
+
const closing = !armed || rounds >= cap || guard.stalled || guard.looping || finishTries > 0;
|
|
397
|
+
const offered = closing ? null : specs.filter((s) => !policy.isSuppressed(s?.name));
|
|
398
|
+
let roundText = '';
|
|
399
|
+
const res = await stream({
|
|
400
|
+
model, messages: convo, tools: offered && offered.length ? offered : null, signal, onActivity,
|
|
401
|
+
// Only when set: a host reads these as "present", not as a value.
|
|
402
|
+
...(redaction !== undefined ? { redaction } : {}), ...(run ? { run } : {}),
|
|
403
|
+
onDelta: (delta) => {
|
|
404
|
+
if (!delta) return;
|
|
405
|
+
if (!roundText && said) { said += ROUND_SEPARATOR; try { onDelta?.(ROUND_SEPARATOR, said); } catch { /* ignore */ } }
|
|
406
|
+
roundText += delta; said += delta;
|
|
407
|
+
try { onDelta?.(delta, said); } catch { /* ignore */ }
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
if (res?.usage) usage = addUsage(usage, res.usage);
|
|
411
|
+
if (res?.noVision) noVision = true;
|
|
412
|
+
// Reconcile: an adapter that returned text without streaming it still gets it into `said`.
|
|
413
|
+
const text = String(res?.text || '');
|
|
414
|
+
if (text && !roundText) { if (said) said += ROUND_SEPARATOR; said += text; roundText = text; }
|
|
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 }); }
|
|
417
|
+
if (res.aborted || signal?.aborted) { finish('aborted'); return closeWith(said, { aborted: true }); }
|
|
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 }));
|
|
420
|
+
if (!wanted.length) {
|
|
421
|
+
finish(res.finish || 'stop');
|
|
422
|
+
usageEvent(said);
|
|
423
|
+
return closeWith(said);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (!offered || !offered.length) {
|
|
427
|
+
// Asked with nothing offered. A relayed agent does this; answer, don't drop.
|
|
428
|
+
finishTries += 1;
|
|
429
|
+
if (finishTries > maxFinishTries) { finish('tool-step-limit'); usageEvent(said); return closeWith(said, { exhausted: true }); }
|
|
430
|
+
exhausted = exhausted || rounds >= cap;
|
|
431
|
+
push(transcript.nudge(wanted, FINISH_NUDGES[Math.min(finishTries - 1, FINISH_NUDGES.length - 1)]));
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
push([transcript.asked(res, wanted)]);
|
|
436
|
+
const { calls, results } = await runner.round(wanted);
|
|
437
|
+
push(transcript.answered(calls.map((call, i) => ({ call, result: results[i] })), { noVision }));
|
|
438
|
+
if (signal?.aborted) { finish('aborted'); return closeWith(said, { aborted: true }); }
|
|
439
|
+
if (guard.looping) push([transcript.system(LOOPING_NUDGE)]);
|
|
440
|
+
if (rounds + 1 >= cap) exhausted = true;
|
|
441
|
+
}
|
|
442
|
+
}
|