flashlite 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/loop.js ADDED
@@ -0,0 +1,442 @@
1
+ // loop.ts — the agent round driver: stream a turn, run tool calls with the permission
2
+ // gate, feed results back, repeat. Dialect-agnostic: it only speaks ChatRequest /
3
+ // AssistantTurn / StreamEvent and an injectable tool executor.
4
+ import { dialectFor } from "./client.js";
5
+ import { effectiveOutputTokens } from "./effort.js";
6
+ import { boundReplay, DEFAULT_TOOL_RESULT_BUDGET_BYTES } from "./contextbudget.js";
7
+ import { checkBudget, estimateTotal } from "./token.js";
8
+ import { recoverLeakedToolCalls } from "./sanitize.js";
9
+ import { createToolCache } from "./toolcache.js";
10
+ function abortError() {
11
+ return new DOMException('The operation was aborted', 'AbortError');
12
+ }
13
+ function denialResult(call) {
14
+ return { toolCallId: call.id, content: JSON.stringify({ error: 'Tool call denied by user' }), isError: true };
15
+ }
16
+ function budgetTexts(system, history, tools) {
17
+ const texts = [system];
18
+ for (const m of history) {
19
+ texts.push(m.content);
20
+ if (m.reasoningContent)
21
+ texts.push(m.reasoningContent);
22
+ for (const c of m.toolCalls ?? [])
23
+ texts.push(c.name + c.argumentsJson);
24
+ for (const block of m.thinkingBlocks ?? [])
25
+ texts.push(JSON.stringify(block));
26
+ }
27
+ for (const t of tools)
28
+ texts.push(t.name + t.description + JSON.stringify(t.parameters));
29
+ return texts;
30
+ }
31
+ export const TOOL_WORKER_CONCURRENCY = 4;
32
+ const MAX_COMPACTION_ATTEMPTS = 3;
33
+ /** Never send unmatched tool blocks, even when loading an older interrupted session. */
34
+ function validToolHistory(history) {
35
+ let pending = new Set();
36
+ for (const message of history) {
37
+ if (message.role === 'tool') {
38
+ if (!message.toolCallId || !pending.delete(message.toolCallId))
39
+ return false;
40
+ continue;
41
+ }
42
+ if (pending.size)
43
+ return false;
44
+ const calls = message.toolCalls ?? [];
45
+ if (calls.length && message.role !== 'assistant')
46
+ return false;
47
+ for (const call of calls) {
48
+ if (!call.id.trim() || pending.has(call.id))
49
+ return false;
50
+ pending.add(call.id);
51
+ }
52
+ }
53
+ return pending.size === 0;
54
+ }
55
+ function validArguments(call) {
56
+ if (call.argumentsInvalid)
57
+ return false;
58
+ try {
59
+ const parsed = JSON.parse(call.argumentsJson);
60
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ export async function runAgentTurn(opts) {
67
+ const dialect = dialectFor(opts.model);
68
+ const outputReserve = effectiveOutputTokens(dialect, opts.maxTokens, opts.effort);
69
+ const toolByDef = new Map(opts.tools.map((t) => [t.name, t]));
70
+ let approveAll = opts.autoApprove;
71
+ let rounds = 0;
72
+ let finishSynced = false;
73
+ let warned80 = false;
74
+ let warned95 = false;
75
+ let exactFailed = false;
76
+ let compactionAttempts = 0;
77
+ let lastCompactionSize;
78
+ let recountCompaction = false;
79
+ let usage;
80
+ const checkpoint = (reason, calls) => ({
81
+ stopReason: reason, usage, rounds,
82
+ interruption: { reason, resumable: true, ...(calls?.length ? { discardedToolCalls: calls } : {}) },
83
+ });
84
+ const progress = (phase, call, note) => {
85
+ // Presentation/logging failures cannot strand committed calls without results.
86
+ try {
87
+ opts.onToolProgress?.(phase, call, note, rounds);
88
+ }
89
+ catch { /* advisory */ }
90
+ };
91
+ const applySteering = () => {
92
+ const messages = opts.takeSteering?.() ?? [];
93
+ opts.history.push(...messages);
94
+ return messages.length > 0;
95
+ };
96
+ const MAX_RECOVERY_RESUMES = 3;
97
+ // Corrupt or unusable provider responses are recoverable: nudge the model to re-issue
98
+ // instead of dead-ending the turn. Bounded, so a model stuck emitting garbage cannot
99
+ // spin forever — this does not cap normal turns, only recovery retries.
100
+ let recoveryResumes = 0;
101
+ const resumeAfterFailure = (note) => {
102
+ if (recoveryResumes >= MAX_RECOVERY_RESUMES)
103
+ return false;
104
+ recoveryResumes += 1;
105
+ opts.history.push({ role: 'user', content: note });
106
+ return true;
107
+ };
108
+ const CORRUPT_PROPOSAL_NOTE = 'Your previous tool call was malformed or corrupted and could not be executed. Re-issue it as one well-formed tool call with valid arguments.';
109
+ const UNUSABLE_RESPONSE_NOTE = 'Your previous response could not be used because it ended unexpectedly. Continue from where you left off.';
110
+ const toolCache = createToolCache();
111
+ while (true) {
112
+ if (opts.signal.aborted)
113
+ throw abortError();
114
+ applySteering();
115
+ if (!validToolHistory(opts.history))
116
+ return checkpoint('invalid_tool_calls');
117
+ opts.history.push(...(opts.takeBackground?.() ?? []));
118
+ const tools = opts.tools;
119
+ const system = opts.system;
120
+ const messages = boundReplay(opts.history, opts.toolResultBudgetBytes ?? DEFAULT_TOOL_RESULT_BUDGET_BYTES, opts.elidedResults, { assistantProse: opts.elideAssistantProse });
121
+ if (opts.contextTokens) {
122
+ const texts = budgetTexts(system, messages, tools);
123
+ // Rough fallback only: image tokens vary by provider/resolution, not base64 length.
124
+ const imageAllowance = messages.reduce((total, m) => total + (m.role === 'user' ? m.images?.length ?? 0 : 0), 0) * 2048;
125
+ let total = estimateTotal(texts) + imageAllowance + messages.length * 16 + 32;
126
+ // Exact counting is a separate network round trip. Far from the limit, a
127
+ // deliberately conservative 2x margin makes the local estimate sufficient.
128
+ // Count exactly again near the window before compaction or rejection.
129
+ const needsExactCount = !opts.fastContextCount || (total + outputReserve) * 2 >= opts.contextTokens;
130
+ if (opts.countTokens && !exactFailed && needsExactCount) {
131
+ try {
132
+ total = await opts.countTokens(opts.model, system, messages, tools, opts.signal);
133
+ if (!Number.isFinite(total) || total < 0)
134
+ throw new Error('invalid token count');
135
+ }
136
+ catch {
137
+ if (opts.signal.aborted)
138
+ throw abortError();
139
+ exactFailed = true; // fall back to the estimate, never retry per turn
140
+ total = estimateTotal(texts) + imageAllowance + messages.length * 16 + 32;
141
+ }
142
+ }
143
+ const budget = checkBudget(total, opts.contextTokens);
144
+ opts.onContextUsage?.(total, opts.contextTokens);
145
+ // Include provider thinking/output floors, not merely the requested ceiling.
146
+ const overfull = total + outputReserve > opts.contextTokens;
147
+ const remainingPercent = Math.max(0, 100 - (total + outputReserve) / opts.contextTokens * 100);
148
+ if (recountCompaction) {
149
+ lastCompactionSize = total;
150
+ recountCompaction = false;
151
+ }
152
+ const meaningfulGrowth = lastCompactionSize === undefined ||
153
+ total >= lastCompactionSize + Math.max(256, Math.ceil(lastCompactionSize * 0.1));
154
+ if (compactionAttempts < MAX_COMPACTION_ATTEMPTS && meaningfulGrowth &&
155
+ (opts.autoCompactRemainingPercent ?? 0) > 0 &&
156
+ remainingPercent <= opts.autoCompactRemainingPercent && opts.compact) {
157
+ compactionAttempts++;
158
+ lastCompactionSize = total;
159
+ let compacted = false;
160
+ try {
161
+ compacted = await opts.compact(opts.history);
162
+ }
163
+ catch {
164
+ if (opts.signal.aborted)
165
+ throw abortError();
166
+ // Summary transport/provider failure must not authorize an overfull request.
167
+ }
168
+ if (compacted) {
169
+ recountCompaction = true;
170
+ continue; // re-count before any request; no retry until meaningful new growth
171
+ }
172
+ }
173
+ if (budget.warn95 && !warned95) {
174
+ warned95 = true;
175
+ warned80 = true;
176
+ opts.onBudget?.(95);
177
+ }
178
+ else if (budget.warn80 && !warned80) {
179
+ warned80 = true;
180
+ opts.onBudget?.(80);
181
+ }
182
+ if (overfull)
183
+ return checkpoint('context_limit');
184
+ }
185
+ const req = {
186
+ model: opts.model,
187
+ dialect,
188
+ messages: [...messages], // snapshot — history grows as the round completes
189
+ system,
190
+ tools,
191
+ stream: true,
192
+ maxTokens: opts.maxTokens,
193
+ effort: opts.effort,
194
+ };
195
+ let streamedText = '';
196
+ let turn;
197
+ try {
198
+ opts.onModelRequest?.();
199
+ turn = await opts.client.chat(req, (event) => {
200
+ if (event.kind === 'text')
201
+ streamedText += event.text;
202
+ opts.onEvent(event);
203
+ }, opts.signal);
204
+ }
205
+ catch (error) {
206
+ // A failed stream may have visible prose, but never a complete tool proposal.
207
+ if (streamedText)
208
+ opts.history.push({ role: 'assistant', content: streamedText });
209
+ throw error;
210
+ }
211
+ if (turn.usage) {
212
+ const cachedInputTokens = (usage?.cachedInputTokens ?? 0) + (turn.usage.cachedInputTokens ?? 0);
213
+ const anyCached = usage?.cachedInputTokens !== undefined || turn.usage.cachedInputTokens !== undefined;
214
+ usage = {
215
+ inputTokens: (usage?.inputTokens ?? 0) + turn.usage.inputTokens,
216
+ outputTokens: (usage?.outputTokens ?? 0) + turn.usage.outputTokens,
217
+ ...(anyCached ? { cachedInputTokens } : {}),
218
+ };
219
+ }
220
+ // Routers proxying a model with a different chat template sometimes leak the
221
+ // native tool-call markup into the text instead of structured tool_calls; recover
222
+ // it so the turn does not dead-end. Truncated/unknown turns keep the existing
223
+ // resume path — never execute proposals the provider did not cleanly finish.
224
+ const leaked = recoverLeakedToolCalls(turn.message);
225
+ if (leaked.stripped && turn.stopReason === 'stop') {
226
+ if (leaked.calls.length)
227
+ turn.stopReason = 'tool_calls';
228
+ else if (!turn.message.content)
229
+ turn.stopReason = 'unknown';
230
+ }
231
+ const calls = turn.message.toolCalls ?? [];
232
+ const seenIds = new Set(); // IDs need only disambiguate this contiguous batch.
233
+ const badIds = calls.some((call) => {
234
+ if (!call.id.trim() || seenIds.has(call.id))
235
+ return true;
236
+ seenIds.add(call.id);
237
+ return false;
238
+ });
239
+ const incomplete = turn.stopReason !== 'tool_calls';
240
+ const invalidProposal = badIds || (turn.stopReason === 'tool_calls' && calls.length === 0);
241
+ if ((calls.length && incomplete) || invalidProposal) {
242
+ // Keep prose only; detached signed thinking/tool blocks are not replayable.
243
+ if (turn.message.content)
244
+ opts.history.push({ role: 'assistant', content: turn.message.content });
245
+ if (opts.signal.aborted)
246
+ throw abortError();
247
+ // Truncation by the output limit is not a failure: the prose is already retained, so
248
+ // resume the turn instead of ending it, letting the model finish and re-issue the tool
249
+ // calls that were dropped for being incomplete.
250
+ if (!invalidProposal && turn.stopReason === 'length' && turn.message.content) {
251
+ continue;
252
+ }
253
+ // A corrupt proposal (bad/duplicate ids, or a tool_calls finish with no calls) is
254
+ // recoverable: have the model re-issue rather than ending the turn here.
255
+ if (invalidProposal && resumeAfterFailure(CORRUPT_PROPOSAL_NOTE))
256
+ continue;
257
+ return checkpoint(invalidProposal ? 'invalid_tool_calls' :
258
+ turn.stopReason === 'length' ? 'length' : 'unknown', calls);
259
+ }
260
+ // A truncated reasoning block may have no valid signature yet.
261
+ if (turn.stopReason === 'length' || turn.stopReason === 'unknown') {
262
+ if (turn.message.content)
263
+ opts.history.push({ role: 'assistant', content: turn.message.content });
264
+ }
265
+ else
266
+ opts.history.push(turn.message);
267
+ if (turn.stopReason !== 'tool_calls') {
268
+ if (opts.signal.aborted)
269
+ throw abortError();
270
+ if (turn.stopReason === 'length') {
271
+ // No valid tool calls in the truncated response, but a cut-off final answer can
272
+ // still be resumed so the model finishes instead of being guillotined.
273
+ if (turn.message?.content)
274
+ continue;
275
+ return checkpoint('length');
276
+ }
277
+ if (turn.stopReason === 'unknown') {
278
+ if (resumeAfterFailure(UNUSABLE_RESPONSE_NOTE))
279
+ continue;
280
+ return checkpoint('unknown');
281
+ }
282
+ if (turn.stopReason === 'stop' && applySteering())
283
+ continue;
284
+ if (turn.stopReason === 'stop') {
285
+ const reports = opts.takeBackground?.() ?? [];
286
+ if (reports.length) {
287
+ opts.history.push(...reports);
288
+ continue;
289
+ }
290
+ }
291
+ if (turn.stopReason === 'stop' && !finishSynced && opts.beforeFinish) {
292
+ finishSynced = true;
293
+ const messages = await opts.beforeFinish();
294
+ if (opts.signal.aborted)
295
+ throw abortError();
296
+ if (messages.length) {
297
+ opts.history.push(...messages);
298
+ continue;
299
+ }
300
+ }
301
+ return { stopReason: turn.stopReason, usage, rounds };
302
+ }
303
+ if (opts.maxRounds !== undefined && rounds >= opts.maxRounds) {
304
+ // The proposal was cleanly completed, but none of these calls are authorized or
305
+ // recorded. Keep only its prose and require an explicit continuation.
306
+ opts.history.pop();
307
+ if (turn.message.content)
308
+ opts.history.push({ role: 'assistant', content: turn.message.content });
309
+ return checkpoint('invalid_tool_calls', calls);
310
+ }
311
+ rounds++;
312
+ // File approve-all never authorizes an always-scoped terminal command.
313
+ const denied = new Set();
314
+ const cancelled = (call) => ({
315
+ toolCallId: call.id, content: JSON.stringify({ error: 'Tool call cancelled before execution' }), isError: true,
316
+ });
317
+ // Resolve argument validity and read-only cache hits up front (pure lookups). The
318
+ // permission gate stays just-in-time: nothing here prompts or executes.
319
+ const precomputed = new Array(calls.length);
320
+ for (const [i, call] of calls.entries()) {
321
+ if (opts.signal.aborted) {
322
+ precomputed[i] = cancelled(call);
323
+ continue;
324
+ }
325
+ if (!validArguments(call)) {
326
+ precomputed[i] = { toolCallId: call.id, content: 'invalid tool arguments', isError: true };
327
+ continue;
328
+ }
329
+ // An identical read-only call already answered in an earlier round is reused rather
330
+ // than re-run; nothing new to approve, so it also skips the permission prompt.
331
+ const cached = toolCache.lookup(call.name, call.arguments);
332
+ if (cached !== undefined)
333
+ precomputed[i] = { toolCallId: call.id, content: cached, isError: false };
334
+ }
335
+ // Approvals are just-in-time: each call is authorized immediately before it runs, so a
336
+ // tool may start while later calls still wait. A single lock serializes the prompt
337
+ // (one modal at a time) while preserving the batch's call order.
338
+ let approvalLock = Promise.resolve();
339
+ const authorize = (call) => {
340
+ const def = toolByDef.get(call.name);
341
+ const always = def?.approvalScope === 'always';
342
+ if (!def?.requiresApproval)
343
+ return Promise.resolve({});
344
+ const task = approvalLock.then(async () => {
345
+ if (opts.signal.aborted)
346
+ return { result: cancelled(call) };
347
+ // File approve-all covers implicit file grants only, never an always-scoped command.
348
+ if (!always && (opts.fileApproval?.() ?? approveAll)) {
349
+ return { grant: { generation: opts.fileApprovalGeneration?.() } };
350
+ }
351
+ let answer;
352
+ try {
353
+ answer = await opts.ask(call);
354
+ }
355
+ catch (err) {
356
+ return { result: opts.signal.aborted ? cancelled(call) : {
357
+ toolCallId: call.id, content: err instanceof Error ? err.message : 'tool approval failed', isError: true,
358
+ } };
359
+ }
360
+ if (opts.signal.aborted)
361
+ return { result: cancelled(call) };
362
+ if (always ? answer !== 'yes' : answer === 'no')
363
+ return { result: denialResult(call), denied: true };
364
+ if (!always && answer === 'all') {
365
+ approveAll = true;
366
+ return { grant: { generation: opts.fileApprovalGeneration?.() } };
367
+ }
368
+ return {};
369
+ });
370
+ approvalLock = task.then(() => { }, () => { });
371
+ return task;
372
+ };
373
+ for (const call of calls)
374
+ if (!opts.signal.aborted)
375
+ progress('start', call);
376
+ const results = new Array(calls.length);
377
+ let nextCall = 0;
378
+ await Promise.all(Array.from({ length: Math.min(TOOL_WORKER_CONCURRENCY, calls.length) }, async () => {
379
+ while (nextCall < calls.length) {
380
+ const i = nextCall++;
381
+ const call = calls[i];
382
+ if (precomputed[i]) {
383
+ results[i] = precomputed[i];
384
+ continue;
385
+ }
386
+ if (opts.signal.aborted) {
387
+ results[i] = cancelled(call);
388
+ continue;
389
+ }
390
+ const approval = await authorize(call);
391
+ if (approval.result) {
392
+ if (approval.denied)
393
+ denied.add(i);
394
+ results[i] = approval.result;
395
+ continue;
396
+ }
397
+ if (opts.signal.aborted) {
398
+ results[i] = cancelled(call);
399
+ continue;
400
+ }
401
+ // Only implicit file-wide grants are revocable here. Explicit per-call yes and
402
+ // always-scoped command grants keep their separate host authorization semantics.
403
+ const fileGrant = approval.grant;
404
+ if (fileGrant && (!(opts.fileApproval?.() ?? approveAll) ||
405
+ fileGrant.generation !== opts.fileApprovalGeneration?.())) {
406
+ denied.add(i);
407
+ results[i] = { toolCallId: call.id, content: JSON.stringify({ error: 'File approval revoked before execution; request approval again' }), isError: true };
408
+ continue;
409
+ }
410
+ try {
411
+ results[i] = await opts.execute(call, opts.signal);
412
+ }
413
+ catch (err) {
414
+ results[i] = { toolCallId: call.id, content: err instanceof Error ? err.message : 'tool execution failed', isError: true };
415
+ }
416
+ // Remember read-only results for later identical calls; any mutating tool clears the cache.
417
+ toolCache.record(call.name, call.arguments, results[i].content, results[i].isError === true);
418
+ }
419
+ }));
420
+ // Pair every recorded call before propagating abort, including approval cancellation.
421
+ for (const [i, result] of results.entries()) {
422
+ const call = calls[i];
423
+ opts.history.push({
424
+ role: 'tool',
425
+ content: result.content,
426
+ toolCallId: call.id,
427
+ isError: result.isError,
428
+ });
429
+ try {
430
+ opts.onToolResult?.(call, result);
431
+ }
432
+ catch { /* presentation is advisory */ }
433
+ progress(denied.has(i) ? 'denied' : result.isError ? 'error' : 'done', call, result.isError ? result.content : undefined);
434
+ }
435
+ try {
436
+ opts.onToolBatchComplete?.();
437
+ }
438
+ catch { /* presentation is advisory */ }
439
+ if (opts.signal.aborted)
440
+ throw abortError();
441
+ }
442
+ }