@alexeiled/pi-model-router 0.5.2 → 0.6.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.
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { setTimeout as delay } from 'node:timers/promises';
2
3
  import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
3
4
  import {
@@ -16,7 +17,6 @@ import type {
16
17
  } from '@earendil-works/pi-coding-agent';
17
18
  import { runClassifier } from './classifier';
18
19
  import {
19
- clampThinkingLevel,
20
20
  collectProfileThinkingLevels,
21
21
  MAX_THINKING_LEVEL,
22
22
  parseCanonicalModelRef,
@@ -26,14 +26,20 @@ import {
26
26
  resolveMaxTokens,
27
27
  } from './config';
28
28
  import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './constants';
29
- import { extractTextFromContent, hasImageAttachment } from './context';
30
29
  import {
31
- buildRoutingDecision,
32
- decideRouting,
33
- phaseForTier,
34
- resolveAvailableTier,
30
+ extractTextFromContent,
31
+ getBoundedRecentContext,
32
+ hasImageAttachment,
33
+ } from './context';
34
+ import { createJevCandidate, runJev } from './jev';
35
+ import {
36
+ availableRoutePairs,
37
+ decisionForPair,
38
+ primaryRoutePairs,
39
+ selectBaselineRoute,
35
40
  } from './routing';
36
41
  import type {
42
+ AdvisorOutcome,
37
43
  RouterConfig,
38
44
  RouterPinByProfile,
39
45
  RouterThinkingByProfile,
@@ -262,6 +268,38 @@ export const registerRouterProvider = (
262
268
  const modelsKey = JSON.stringify(modelDefinitions);
263
269
  if (state.lastRegisteredModels === modelsKey) return;
264
270
 
271
+ // Runtime only: no task identities or branch metadata enter persisted decisions.
272
+ type ContinuationRecord = {
273
+ turn: string;
274
+ policy: string;
275
+ branch: string[];
276
+ decision: RoutingDecision;
277
+ toolCalls: Set<string>;
278
+ config: RouterConfig;
279
+ };
280
+ // Streams can complete out of order. Keep a small turn-keyed history rather
281
+ // than letting the latest stream replace another stream's continuation.
282
+ const continuations = new Map<string, ContinuationRecord>();
283
+ const advisedTurns = new Map<string, AdvisorOutcome>();
284
+ const rememberContinuation = (record: ContinuationRecord) => {
285
+ continuations.delete(record.turn);
286
+ continuations.set(record.turn, record);
287
+ while (continuations.size > 16) {
288
+ const oldest = continuations.keys().next().value;
289
+ if (oldest === undefined) break;
290
+ continuations.delete(oldest);
291
+ }
292
+ };
293
+ const rememberAdvisedTurn = (turn: string, outcome: AdvisorOutcome) => {
294
+ advisedTurns.delete(turn);
295
+ advisedTurns.set(turn, outcome);
296
+ while (advisedTurns.size > 16) {
297
+ const oldest = advisedTurns.keys().next().value;
298
+ if (oldest === undefined) break;
299
+ advisedTurns.delete(oldest);
300
+ }
301
+ };
302
+
265
303
  pi.registerProvider('router', {
266
304
  baseUrl: 'router://local',
267
305
  apiKey: 'pi-model-router',
@@ -276,6 +314,8 @@ export const registerRouterProvider = (
276
314
 
277
315
  void (async () => {
278
316
  let partialMessage: AssistantMessage | undefined;
317
+ let activeTurn: string | undefined;
318
+ let generationSucceeded = false;
279
319
  try {
280
320
  // Wait for the router to be fully initialized (session_start sets currentModelRegistry).
281
321
  // This handles the race where subagents (e.g. from pi-dynamic-workflows) invoke
@@ -295,6 +335,7 @@ export const registerRouterProvider = (
295
335
  throw new Error(`Unknown router profile: ${model.id}`);
296
336
  }
297
337
 
338
+ options?.signal?.throwIfAborted();
298
339
  state.selectedProfile = model.id;
299
340
  state.routerEnabled = true;
300
341
 
@@ -303,131 +344,297 @@ export const registerRouterProvider = (
303
344
  state.currentConfig.maxSessionBudget !== undefined &&
304
345
  state.accumulatedCost >= state.currentConfig.maxSessionBudget;
305
346
 
306
- let decision: RoutingDecision = decideRouting(
307
- context,
347
+ const imageAttached = hasImageAttachment(context);
348
+ const findModel = (provider: string, id: string) =>
349
+ registry.find(provider, id);
350
+ const thinkingOverrides = state.thinkingByProfile[model.id];
351
+ const available = () =>
352
+ availableRoutePairs(
353
+ profile,
354
+ findModel,
355
+ imageAttached,
356
+ thinkingOverrides,
357
+ );
358
+ let pairs = available();
359
+ const lastUserIndex = context.messages.findLastIndex(
360
+ (entry) => entry.role === 'user',
361
+ );
362
+ const user = context.messages[lastUserIndex];
363
+ const turn =
364
+ user?.role === 'user' && user.timestamp > 0
365
+ ? createHash('sha256')
366
+ .update(
367
+ JSON.stringify(
368
+ context.messages.slice(0, lastUserIndex + 1),
369
+ ),
370
+ )
371
+ .digest('hex')
372
+ : undefined;
373
+ activeTurn = turn;
374
+ const branch =
375
+ state.lastExtensionContext?.sessionManager
376
+ .getBranch()
377
+ .filter(
378
+ (entry) =>
379
+ entry.type !== 'custom' ||
380
+ entry.customType !== 'router-state',
381
+ )
382
+ .map((entry) => entry.id) ?? [];
383
+ // Pi resolves authentication per request. This validates provider/model
384
+ // identity, not the backend account behind that provider.
385
+ const policy = JSON.stringify([
308
386
  model.id,
309
387
  profile,
310
- state.lastDecision,
311
388
  pinnedTier,
312
- state.thinkingByProfile[model.id],
313
- state.currentConfig.phaseBias,
314
- state.currentConfig.rules,
389
+ thinkingOverrides,
390
+ state.currentConfig.classifierModel,
315
391
  isBudgetExceeded,
392
+ ]);
393
+ const toolContinuation =
394
+ context.messages.at(-1)?.role === 'toolResult';
395
+ const jev = state.currentConfig.jev;
396
+ const useJev = Boolean(
397
+ jev?.enabled &&
398
+ profile.jev?.enabled &&
399
+ jev.apiKey.trim().length > 0,
400
+ );
401
+ const advisorConfigured =
402
+ useJev || Boolean(state.currentConfig.classifierModel);
403
+ const continuationRecord =
404
+ toolContinuation && turn ? continuations.get(turn) : undefined;
405
+ const continuationDecision = continuationRecord?.decision;
406
+ const latestAssistantIndex = context.messages.findLastIndex(
407
+ (entry) => entry.role === 'assistant',
316
408
  );
409
+ const latestResults = context.messages
410
+ .slice(latestAssistantIndex + 1)
411
+ .filter((entry) => entry.role === 'toolResult');
412
+ const latestAssistant = context.messages[latestAssistantIndex];
413
+ const reusable =
414
+ toolContinuation &&
415
+ continuationRecord &&
416
+ continuationDecision &&
417
+ turn &&
418
+ continuationRecord.turn === turn &&
419
+ continuationRecord.policy === policy &&
420
+ continuationRecord.config === state.currentConfig &&
421
+ continuationDecision?.profile === model.id &&
422
+ branch.length > 0 &&
423
+ branch.every((id) => typeof id === 'string' && id.length > 0) &&
424
+ continuationRecord.branch.length > 0 &&
425
+ continuationRecord.branch.every((id, i) => branch[i] === id) &&
426
+ latestResults.length > 0 &&
427
+ latestAssistant?.role === 'assistant' &&
428
+ latestAssistant.provider === continuationDecision.targetProvider &&
429
+ latestAssistant.model === continuationDecision.targetModelId &&
430
+ latestResults.every((entry) =>
431
+ latestAssistant.content.some(
432
+ (part) =>
433
+ part.type === 'toolCall' && part.id === entry.toolCallId,
434
+ ),
435
+ ) &&
436
+ latestResults.every((entry) =>
437
+ continuationRecord.toolCalls.has(entry.toolCallId),
438
+ ) &&
439
+ pairs.some(
440
+ (pair) =>
441
+ pair.tier === continuationDecision.tier &&
442
+ pair.model === continuationDecision.targetLabel &&
443
+ pair.thinking === continuationDecision.thinking,
444
+ );
445
+ let decision: RoutingDecision;
446
+ if (reusable && continuationDecision) {
447
+ decision = {
448
+ ...continuationDecision,
449
+ reasonCode: 'continuation',
450
+ // Advisor diagnostics describe the original routing attempt only.
451
+ isClassifier: undefined,
452
+ routingLatencyMs: undefined,
453
+ errorClass: undefined,
454
+ timestamp: Date.now(),
455
+ };
456
+ } else {
457
+ if (toolContinuation && turn) continuations.delete(turn);
458
+ const baseline = selectBaselineRoute(
459
+ model.id,
460
+ profile,
461
+ pairs,
462
+ pinnedTier,
463
+ isBudgetExceeded,
464
+ );
465
+ decision = decisionForPair(
466
+ model.id,
467
+ baseline.pair,
468
+ baseline.reasonCode,
469
+ );
470
+ decision.isBudgetForced = baseline.isBudgetForced;
471
+ decision.advisor = advisorConfigured ? 'bypassed' : 'none';
472
+ if (!toolContinuation && turn) {
473
+ const previousAdvisor = advisedTurns.get(turn);
474
+ if (previousAdvisor) decision.advisor = previousAdvisor;
475
+ }
476
+ }
317
477
 
318
- // Classifier Override skip when budget is already exceeded since the
319
- // result would be downgraded anyway, saving an unnecessary LLM call.
478
+ // Tool results never invoke advisors, even when their prior route cannot be reused.
320
479
  if (
321
- state.currentConfig.classifierModel &&
480
+ !toolContinuation &&
322
481
  !pinnedTier &&
323
- !decision.isRuleMatched &&
324
- !isBudgetExceeded
482
+ !isBudgetExceeded &&
483
+ user &&
484
+ turn &&
485
+ !advisedTurns.has(turn) &&
486
+ advisorConfigured
325
487
  ) {
326
- const classifierResult = await runClassifier(
327
- state.currentConfig.classifierModel.model,
328
- registry,
329
- context,
330
- state.lastDecision?.profile === model.id
331
- ? state.lastDecision.phase
332
- : undefined,
333
- state.currentConfig.classifierModel.thinking,
334
- options?.signal,
488
+ const started = performance.now();
489
+ const routingDeadline = started + (useJev ? 1500 : 10_000);
490
+ const candidates = primaryRoutePairs(profile, pairs).map(
491
+ createJevCandidate,
335
492
  );
336
- options?.signal?.throwIfAborted();
337
- if (classifierResult) {
338
- const tier = resolveAvailableTier(profile, classifierResult.tier);
339
- decision = buildRoutingDecision(
493
+ // A single primary bypasses advice, not a baseline's eligible fallback.
494
+ if (candidates.length <= 1) {
495
+ decision.advisor = 'bypassed';
496
+ rememberAdvisedTurn(turn, 'bypassed');
497
+ } else if (useJev && jev) {
498
+ decision.advisor = 'jev';
499
+ rememberAdvisedTurn(turn, 'jev');
500
+ const advice = await runJev(
501
+ {
502
+ ...jev,
503
+ timeoutMs: Math.min(750, jev.timeoutMs),
504
+ },
505
+ {
506
+ taskSummary: getBoundedRecentContext(
507
+ context,
508
+ jev.maxStateChars,
509
+ ),
510
+ candidates,
511
+ profile: profile.jev,
512
+ routingDeadline,
513
+ signal: options?.signal,
514
+ },
515
+ ).catch(() => undefined);
516
+ options?.signal?.throwIfAborted();
517
+ // Re-read registry capabilities after the network boundary.
518
+ pairs = available();
519
+ const candidate = candidates.find(
520
+ (entry) => entry.id === advice?.candidateId,
521
+ );
522
+ if (
523
+ candidate &&
524
+ performance.now() < routingDeadline &&
525
+ pairs.some(
526
+ (pair) =>
527
+ pair.model === candidate.model &&
528
+ pair.tier === candidate.tier &&
529
+ pair.thinking === candidate.thinking,
530
+ )
531
+ ) {
532
+ decision = {
533
+ ...decisionForPair(model.id, candidate, 'jev'),
534
+ advisor: 'jev',
535
+ };
536
+ } else {
537
+ const baseline = selectBaselineRoute(model.id, profile, pairs);
538
+ decision = decisionForPair(
539
+ model.id,
540
+ baseline.pair,
541
+ baseline.reasonCode,
542
+ );
543
+ decision.advisor = 'jev-fallback';
544
+ rememberAdvisedTurn(turn, 'jev-fallback');
545
+ decision.errorClass = 'advisor-unavailable';
546
+ }
547
+ decision.routingLatencyMs = Math.max(
548
+ 0,
549
+ performance.now() - started,
550
+ );
551
+ if (performance.now() >= routingDeadline)
552
+ decision.errorClass = 'deadline';
553
+ } else if (state.currentConfig.classifierModel) {
554
+ const classifier = state.currentConfig.classifierModel;
555
+ const result = await runClassifier(
556
+ classifier.model,
557
+ registry,
558
+ context,
559
+ undefined,
560
+ classifier.thinking,
561
+ options?.signal,
562
+ routingDeadline,
563
+ ).catch(() => undefined);
564
+ options?.signal?.throwIfAborted();
565
+ pairs = available();
566
+ const baseline = selectBaselineRoute(model.id, profile, pairs);
567
+ decision = decisionForPair(
340
568
  model.id,
341
- profile,
342
- tier,
343
- phaseForTier(tier),
344
- `Classifier: ${classifierResult.reasoning}`,
345
- state.thinkingByProfile[model.id],
346
- true,
569
+ baseline.pair,
570
+ baseline.reasonCode,
571
+ );
572
+ if (result && performance.now() < routingDeadline) {
573
+ const pair = pairs.find((entry) => entry.tier === result.tier);
574
+ if (pair) {
575
+ decision = {
576
+ ...decisionForPair(model.id, pair, 'classifier'),
577
+ isClassifier: true,
578
+ advisor: 'classifier',
579
+ };
580
+ rememberAdvisedTurn(turn, 'classifier');
581
+ } else {
582
+ decision.advisor = 'classifier-fallback';
583
+ rememberAdvisedTurn(turn, 'classifier-fallback');
584
+ decision.errorClass = 'advisor-unavailable';
585
+ }
586
+ } else {
587
+ decision.advisor = 'classifier-fallback';
588
+ rememberAdvisedTurn(turn, 'classifier-fallback');
589
+ decision.errorClass = 'advisor-unavailable';
590
+ }
591
+ decision.routingLatencyMs = Math.max(
592
+ 0,
593
+ performance.now() - started,
347
594
  );
595
+ if (performance.now() >= routingDeadline)
596
+ decision.errorClass = 'deadline';
348
597
  }
349
598
  }
350
599
 
351
- const lastMessage = context.messages[context.messages.length - 1];
352
- const previousDecision = state.lastDecision;
353
- const isGoogleThinkingToolContinuation =
354
- lastMessage?.role === 'toolResult' &&
355
- previousDecision?.profile === model.id &&
356
- previousDecision.targetProvider === 'google' &&
357
- previousDecision.thinking !== 'off' &&
358
- decision.targetProvider === 'google' &&
359
- decision.thinking !== 'off' &&
360
- previousDecision.targetLabel !== decision.targetLabel;
361
-
362
- if (isGoogleThinkingToolContinuation && previousDecision) {
600
+ // Google thought signatures cannot be replayed against a different thinking model.
601
+ const priorAssistant = context.messages[latestAssistantIndex];
602
+ if (
603
+ toolContinuation &&
604
+ priorAssistant?.role === 'assistant' &&
605
+ priorAssistant.provider === 'google' &&
606
+ priorAssistant.content.some(
607
+ (entry) =>
608
+ entry.type === 'thinking' ||
609
+ (entry.type === 'toolCall' && entry.thoughtSignature),
610
+ ) &&
611
+ (decision.targetProvider !== priorAssistant.provider ||
612
+ decision.targetModelId !== priorAssistant.model)
613
+ ) {
614
+ const priorPair =
615
+ pairs.find(
616
+ (pair) =>
617
+ pair.model ===
618
+ `${priorAssistant.provider}/${priorAssistant.model}` &&
619
+ pair.tier === continuationDecision?.tier &&
620
+ pair.thinking === continuationDecision?.thinking,
621
+ ) ??
622
+ pairs.find(
623
+ (pair) =>
624
+ pair.model ===
625
+ `${priorAssistant.provider}/${priorAssistant.model}`,
626
+ );
627
+ if (!priorPair)
628
+ throw new Error(
629
+ 'No compatible route for Google tool continuation.',
630
+ );
363
631
  decision = {
364
- ...decision,
365
- tier: previousDecision.tier,
366
- phase: previousDecision.phase,
367
- targetProvider: previousDecision.targetProvider,
368
- targetModelId: previousDecision.targetModelId,
369
- targetLabel: previousDecision.targetLabel,
370
- thinking: previousDecision.thinking,
371
- reasoning:
372
- `Preserved ${previousDecision.targetLabel} for a Google tool-result continuation ` +
373
- `to avoid thought-signature replay errors. (Original: ${decision.reasoning})`,
632
+ ...decisionForPair(model.id, priorPair, 'continuation'),
633
+ advisor: decision.advisor,
374
634
  };
375
635
  }
376
636
 
377
- const imageAttached = hasImageAttachment(context);
378
- const checkModelSupportsImage = (modelRef: string) => {
379
- try {
380
- const { provider, modelId } = parseCanonicalModelRef(modelRef);
381
- const m = registry.find(provider, modelId);
382
- return m?.input?.includes('image') ?? false;
383
- } catch {
384
- return false;
385
- }
386
- };
387
-
388
- if (imageAttached) {
389
- const tierModels = [
390
- decision.targetLabel,
391
- ...(profile[decision.tier]?.fallbacks ?? []),
392
- ];
393
- if (!tierModels.some(checkModelSupportsImage)) {
394
- const tiersToTry: RouterTier[] =
395
- decision.tier === 'low'
396
- ? ['medium', 'high']
397
- : decision.tier === 'medium'
398
- ? ['high']
399
- : [];
400
-
401
- let foundTier: RouterTier | undefined;
402
- for (const t of tiersToTry) {
403
- const tierConfig = profile[t];
404
- if (!tierConfig) continue;
405
- const tModels = [
406
- tierConfig.model,
407
- ...(tierConfig.fallbacks ?? []),
408
- ];
409
- if (tModels.some(checkModelSupportsImage)) {
410
- foundTier = t;
411
- break;
412
- }
413
- }
414
-
415
- if (foundTier) {
416
- decision = buildRoutingDecision(
417
- model.id,
418
- profile,
419
- foundTier,
420
- phaseForTier(foundTier),
421
- `Forced ${foundTier} tier because the originally routed ${decision.tier} tier does not support image attachments.`,
422
- state.thinkingByProfile[model.id],
423
- false,
424
- );
425
- }
426
- }
427
- }
428
-
429
637
  state.lastDecision = decision;
430
- actions.recordDebugDecision(decision);
431
638
 
432
639
  // Sync pi's thinking level display with the router's effective thinking.
433
640
  // Wrapped in try/catch: in subagent contexts the extension runtime
@@ -444,20 +651,11 @@ export const registerRouterProvider = (
444
651
  // Stale extension context — skip non-critical UI updates.
445
652
  }
446
653
 
447
- let modelsToTry = [
448
- ...new Set([
449
- decision.targetLabel,
450
- ...(profile[decision.tier]?.fallbacks ?? []),
451
- ]),
452
- ];
453
- if (imageAttached) {
454
- modelsToTry = modelsToTry.filter(checkModelSupportsImage);
455
- if (modelsToTry.length === 0) {
456
- throw new Error(
457
- 'No configured model supports image attachments.',
458
- );
459
- }
460
- }
654
+ // Explicit fallback refs authorize provider changes; never discover other accounts.
655
+ const modelsToTry = [
656
+ decision.targetLabel,
657
+ ...(profile[decision.tier]?.fallbacks ?? []),
658
+ ].filter((ref, i, refs) => refs.indexOf(ref) === i);
461
659
  let lastError: unknown;
462
660
  let success = false;
463
661
 
@@ -491,26 +689,33 @@ export const registerRouterProvider = (
491
689
  effectiveContext = truncateContext(context, targetLimit);
492
690
  }
493
691
 
494
- const thinkingOverride = actions.getThinkingOverride(
495
- model.id,
496
- decision.tier,
692
+ const pair = available().find(
693
+ (candidate) =>
694
+ candidate.tier === decision.tier &&
695
+ candidate.model === modelRef,
497
696
  );
498
- let requestedReasoning = thinkingOverride ?? decision.thinking;
499
-
500
- if (requestedReasoning !== 'off' && targetModel.reasoning) {
501
- const tierConfig = profile[decision.tier];
502
- if (tierConfig?.resolvedThinkingLevels) {
503
- requestedReasoning = clampThinkingLevel(
504
- requestedReasoning,
505
- tierConfig.resolvedThinkingLevels,
506
- );
507
- }
697
+ if (!pair)
698
+ throw new Error(
699
+ 'Routed model capabilities or thinking are unsupported.',
700
+ );
701
+ if (
702
+ toolContinuation &&
703
+ priorAssistant?.role === 'assistant' &&
704
+ priorAssistant.provider === 'google' &&
705
+ priorAssistant.content.some(
706
+ (entry) =>
707
+ entry.type === 'thinking' ||
708
+ (entry.type === 'toolCall' && entry.thoughtSignature),
709
+ ) &&
710
+ (targetProvider !== priorAssistant.provider ||
711
+ targetModelId !== priorAssistant.model)
712
+ ) {
713
+ throw new Error(
714
+ 'No compatible route for Google tool continuation.',
715
+ );
508
716
  }
509
-
510
717
  const delegatedReasoning: SimpleStreamOptions['reasoning'] =
511
- targetModel.reasoning && requestedReasoning !== 'off'
512
- ? requestedReasoning
513
- : undefined;
718
+ pair.thinking !== 'off' ? pair.thinking : undefined;
514
719
 
515
720
  try {
516
721
  if (state.lastExtensionContext) {
@@ -554,7 +759,13 @@ export const registerRouterProvider = (
554
759
  decision.targetModelId = targetModelId;
555
760
  decision.targetLabel = modelRef;
556
761
  decision.thinking = delegatedReasoning ?? 'off';
557
- decision.isFallback = i > 0;
762
+ decision.isFallback =
763
+ i > 0 || modelRef !== profile[decision.tier]?.model;
764
+ if (
765
+ decision.isFallback &&
766
+ decision.reasonCode !== 'continuation'
767
+ )
768
+ decision.reasonCode = 'fallback';
558
769
  };
559
770
  for await (const event of delegatedStream) {
560
771
  if (
@@ -585,10 +796,25 @@ export const registerRouterProvider = (
585
796
  }
586
797
  if (event.type === 'done' || event.type === 'error') {
587
798
  terminalReceived = true;
799
+ generationSucceeded = event.type === 'done';
588
800
  recordTarget();
589
801
  const cost = (
590
802
  event.type === 'done' ? event.message : event.error
591
803
  ).usage.cost.total;
804
+ if (event.type === 'done' && turn) {
805
+ rememberContinuation({
806
+ turn,
807
+ policy,
808
+ branch,
809
+ decision,
810
+ config: state.currentConfig,
811
+ toolCalls: new Set(
812
+ event.message.content.flatMap((entry) =>
813
+ entry.type === 'toolCall' ? [entry.id] : [],
814
+ ),
815
+ ),
816
+ });
817
+ }
592
818
  if (Number.isFinite(cost) && cost > 0)
593
819
  state.accumulatedCost += cost;
594
820
  }
@@ -624,6 +850,7 @@ export const registerRouterProvider = (
624
850
  );
625
851
  }
626
852
 
853
+ actions.recordDebugDecision(decision);
627
854
  stream.end();
628
855
  } catch (error) {
629
856
  const reason = options?.signal?.aborted ? 'aborted' : 'error';
@@ -639,6 +866,8 @@ export const registerRouterProvider = (
639
866
  });
640
867
  stream.end();
641
868
  } finally {
869
+ if (!generationSucceeded && activeTurn)
870
+ advisedTurns.delete(activeTurn);
642
871
  try {
643
872
  actions.persistState();
644
873
  } catch {