@chatpanel/events 0.94.0 → 0.95.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/index.js +12 -1
- package/package.json +5 -1
- package/tool-hints.js +7 -1
- package/tool-loop-guard.js +184 -0
- package/tool-traits.js +27 -3
- package/turn-loop.js +441 -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/index.js
CHANGED
|
@@ -170,8 +170,19 @@ 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
|
+
export {
|
|
182
|
+
runTurnLoop, createCallRunner, roundCap, withToolSystem, describeCall as describeToolCall, stepResultText, addUsage, normalizeUsage,
|
|
183
|
+
openAiTranscript, anthropicTranscript,
|
|
184
|
+
DEFAULT_MAX_ROUNDS, DEFAULT_MAX_FINISH_TRIES, FINISH_NUDGES, LOOPING_NUDGE, EXHAUSTED_NOTE, ROUND_SEPARATOR,
|
|
185
|
+
} from './turn-loop.js';
|
|
175
186
|
export {
|
|
176
187
|
createResultStore, shieldToolResult, runResultQuery, withResultShield, describeShape, compactValue,
|
|
177
188
|
resultToolSpec, RESULT_TOOL_NAME, DEFAULT_SHIELD, DEFAULT_STORE,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.95.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",
|
|
@@ -120,11 +120,13 @@
|
|
|
120
120
|
"./tool-hints.js": "./tool-hints.js",
|
|
121
121
|
"./tool-need.js": "./tool-need.js",
|
|
122
122
|
"./tool-result.js": "./tool-result.js",
|
|
123
|
+
"./tool-loop-guard.js": "./tool-loop-guard.js",
|
|
123
124
|
"./tool-round.js": "./tool-round.js",
|
|
124
125
|
"./tool-schema.js": "./tool-schema.js",
|
|
125
126
|
"./tool-traits.js": "./tool-traits.js",
|
|
126
127
|
"./toolset.js": "./toolset.js",
|
|
127
128
|
"./trajectory.js": "./trajectory.js",
|
|
129
|
+
"./turn-loop.js": "./turn-loop.js",
|
|
128
130
|
"./upcast.js": "./upcast.js",
|
|
129
131
|
"./vault.js": "./vault.js",
|
|
130
132
|
"./view.js": "./view.js",
|
|
@@ -254,11 +256,13 @@
|
|
|
254
256
|
"tool-hints.js",
|
|
255
257
|
"tool-need.js",
|
|
256
258
|
"tool-result.js",
|
|
259
|
+
"tool-loop-guard.js",
|
|
257
260
|
"tool-round.js",
|
|
258
261
|
"tool-schema.js",
|
|
259
262
|
"tool-traits.js",
|
|
260
263
|
"toolset.js",
|
|
261
264
|
"trajectory.js",
|
|
265
|
+
"turn-loop.js",
|
|
262
266
|
"upcast.js",
|
|
263
267
|
"vault.js",
|
|
264
268
|
"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,441 @@
|
|
|
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
|
+
if (!res?.ok) { finish('error'); return result({ ok: false, error: res?.error || 'the model did not answer', aborted: !!res?.aborted, transcript: text.trim() ? [...convo, transcript.said(said)] : convo }); }
|
|
416
|
+
if (res.aborted || signal?.aborted) { finish('aborted'); return closeWith(said, { aborted: true }); }
|
|
417
|
+
|
|
418
|
+
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 }));
|
|
419
|
+
if (!wanted.length) {
|
|
420
|
+
finish(res.finish || 'stop');
|
|
421
|
+
usageEvent(said);
|
|
422
|
+
return closeWith(said);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (!offered || !offered.length) {
|
|
426
|
+
// Asked with nothing offered. A relayed agent does this; answer, don't drop.
|
|
427
|
+
finishTries += 1;
|
|
428
|
+
if (finishTries > maxFinishTries) { finish('tool-step-limit'); usageEvent(said); return closeWith(said, { exhausted: true }); }
|
|
429
|
+
exhausted = exhausted || rounds >= cap;
|
|
430
|
+
push(transcript.nudge(wanted, FINISH_NUDGES[Math.min(finishTries - 1, FINISH_NUDGES.length - 1)]));
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
push([transcript.asked(res, wanted)]);
|
|
435
|
+
const { calls, results } = await runner.round(wanted);
|
|
436
|
+
push(transcript.answered(calls.map((call, i) => ({ call, result: results[i] })), { noVision }));
|
|
437
|
+
if (signal?.aborted) { finish('aborted'); return closeWith(said, { aborted: true }); }
|
|
438
|
+
if (guard.looping) push([transcript.system(LOOPING_NUDGE)]);
|
|
439
|
+
if (rounds + 1 >= cap) exhausted = true;
|
|
440
|
+
}
|
|
441
|
+
}
|