larkin 0.4.1 → 0.4.5

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.
Files changed (42) hide show
  1. package/README.md +76 -33
  2. package/artifacts/release/THIRD_PARTY_NOTICES.txt +218 -228
  3. package/assets/readme/larkin-github-cover.png +0 -0
  4. package/assets/readme/larkin-hero.svg +111 -0
  5. package/dist/agent/agent-state-store.cjs +193 -1
  6. package/dist/agent/agent-state-store.mjs +191 -0
  7. package/dist/app/agent-config.mjs +73 -6
  8. package/dist/app/agent-readiness.cjs +22 -2
  9. package/dist/app/agent-readiness.mjs +20 -2
  10. package/dist/app/binary-entry.mjs +6 -0
  11. package/dist/app/cli.mjs +7 -1
  12. package/dist/app/local-control.mjs +108 -2
  13. package/dist/app/run.mjs +8 -0
  14. package/dist/app/runtime-process.mjs +17 -1
  15. package/dist/app/session-cli.mjs +55 -20
  16. package/dist/dashboard/dashboard-view-model.mjs +12 -3
  17. package/dist/feishu/host-shell.cjs +104 -9
  18. package/dist/feishu/host-shell.mjs +105 -10
  19. package/dist/platform/config.cjs +406 -33
  20. package/dist/platform/config.mjs +405 -33
  21. package/dist/platform/process-boundary-trace.cjs +108 -0
  22. package/dist/platform/process-boundary-trace.mjs +102 -0
  23. package/dist/runtime/pi-artifact-provenance.cjs +147 -0
  24. package/dist/runtime/pi-artifact-provenance.mjs +139 -0
  25. package/dist/runtime/pi-compaction-recovery.cjs +703 -0
  26. package/dist/runtime/pi-compaction-recovery.mjs +679 -0
  27. package/dist/runtime/pi-model-catalog.cjs +2 -2
  28. package/dist/runtime/pi-model-catalog.mjs +2 -2
  29. package/dist/runtime/pi-profile-migration.cjs +556 -0
  30. package/dist/runtime/pi-profile-migration.mjs +515 -0
  31. package/dist/runtime/pi-provider-config.cjs +3 -3
  32. package/dist/runtime/pi-provider-config.mjs +3 -3
  33. package/dist/runtime/pi-rpc-client.cjs +39 -22
  34. package/dist/runtime/pi-rpc-client.mjs +39 -22
  35. package/dist/runtime/provider-error-classifier.cjs +39 -0
  36. package/dist/runtime/provider-error-classifier.mjs +34 -0
  37. package/dist/runtime/runtime-adapters.mjs +176 -27
  38. package/dist/runtime/runtime-host.cjs +630 -19
  39. package/dist/runtime/runtime-host.mjs +628 -18
  40. package/dist/runtime/runtime-readiness.cjs +6 -4
  41. package/dist/runtime/runtime-readiness.mjs +6 -4
  42. package/package.json +3 -3
@@ -4,9 +4,11 @@ import path from "node:path";
4
4
  import { agentCliPromptCapabilities } from "../agent/agent-cli-capabilities.mjs";
5
5
  import { isCanonicalInboxTarget, targetKeyOfInboxEnvelope } from "../agent/inbox-projection.mjs";
6
6
  import { SpanKind } from "@opentelemetry/api";
7
+ import { buildStrictProviderErrorInput, classifyStrictProviderError } from "./provider-error-classifier.mjs";
7
8
  import { classifyRuntimePrerequisite, providerAuthenticationFailureReadiness, RuntimePrerequisiteError, } from "./runtime-readiness.mjs";
8
9
  import { resolveOfficialLarkCli } from "../app/official-lark-cli.mjs";
9
10
  import { assertAgentWorkspaceBound, managedLarkCliEnv } from "../app/agent-lark-cli-workspace.mjs";
11
+ import { isPiNativeCompactionRequired, PiCompactionBreaker, PiCompactionRecoveryMachine, } from "./pi-compaction-recovery.mjs";
10
12
  export class RuntimeSessionResetError extends Error {
11
13
  code;
12
14
  pendingCount;
@@ -17,6 +19,16 @@ export class RuntimeSessionResetError extends Error {
17
19
  this.name = "RuntimeSessionResetError";
18
20
  }
19
21
  }
22
+ export class RuntimeSessionRecoveryError extends Error {
23
+ code;
24
+ pendingCount;
25
+ constructor(code, message, pendingCount = 0) {
26
+ super(message);
27
+ this.code = code;
28
+ this.pendingCount = pendingCount;
29
+ this.name = "RuntimeSessionRecoveryError";
30
+ }
31
+ }
20
32
  const MAX_DELIVERIES = 2048;
21
33
  const now = () => new Date().toISOString();
22
34
  const isActiveDelivery = (status) => ["pending", "submitting", "accepted"].includes(status);
@@ -106,6 +118,9 @@ export function createRuntimeHost(options) {
106
118
  const listeners = new Set();
107
119
  const log = options.log ?? (() => { });
108
120
  const telemetry = options.telemetry;
121
+ const compactTimeoutMs = options.compactTimeoutMs ?? 120_000;
122
+ if (!Number.isFinite(compactTimeoutMs) || compactTimeoutMs <= 0)
123
+ throw new Error("compactTimeoutMs must be positive");
109
124
  const retryPolicy = {
110
125
  baseDelayMs: options.retryPolicy?.baseDelayMs ?? 250,
111
126
  maxDelayMs: options.retryPolicy?.maxDelayMs ?? 10_000,
@@ -234,6 +249,7 @@ export function createRuntimeHost(options) {
234
249
  scrubQuarantinedInput(record, reason);
235
250
  record.reason = reason;
236
251
  record.retryable = false;
252
+ delete record.errorCategory;
237
253
  const finalRecord = setRecord(agent, record, "error");
238
254
  if (finalRecord.status === "consumed")
239
255
  return { status: "accepted", deliveryId: record.deliveryId };
@@ -372,7 +388,10 @@ export function createRuntimeHost(options) {
372
388
  };
373
389
  const resultFor = (agent, record, result) => {
374
390
  if (result.status === "accepted") {
375
- delete record.reason;
391
+ if (record.errorCategory !== "context_window") {
392
+ delete record.reason;
393
+ delete record.errorCategory;
394
+ }
376
395
  delete record.retryable;
377
396
  const finalRecord = setRecord(agent, record, "accepted");
378
397
  if (finalRecord.status !== "consumed")
@@ -380,8 +399,11 @@ export function createRuntimeHost(options) {
380
399
  deliveryId: record.deliveryId, messageId: record.messageId, status: "accepted" });
381
400
  return { status: "accepted", deliveryId: record.deliveryId };
382
401
  }
402
+ if (record.errorCategory !== "context_window")
403
+ delete record.errorCategory;
383
404
  const retryable = result.status === "deferred" || result.retryable;
384
- record.reason = result.reason;
405
+ if (record.errorCategory !== "context_window")
406
+ record.reason = result.reason;
385
407
  record.retryable = retryable;
386
408
  const finalRecord = setRecord(agent, record, retryable ? "pending" : "error");
387
409
  if (finalRecord.status === "consumed")
@@ -393,6 +415,22 @@ export function createRuntimeHost(options) {
393
415
  : { status: "error", deliveryId: record.deliveryId, reason: result.reason, retryable: false };
394
416
  };
395
417
  const submit = async (agent, record, busy) => {
418
+ const proactive = agent.piProactiveCompaction
419
+ && agent.piProactiveCompactionGeneration === agent.generation
420
+ && agent.piProactiveCompactionSession === agent.session
421
+ ? agent.piProactiveCompaction : null;
422
+ if (proactive && await proactive === "failed") {
423
+ return { status: "deferred", deliveryId: record.deliveryId, reason: "Pi proactive compaction failed for the current session generation" };
424
+ }
425
+ if (agent.adapter.id === "pi" && agent.piProactiveCompactionFailedGeneration === agent.generation) {
426
+ return { status: "deferred", deliveryId: record.deliveryId, reason: "Pi proactive compaction failed for the current session generation" };
427
+ }
428
+ if (agent.adapter.id === "pi" && !busy && !agent.busy && !agent.turnInProgress && agent.session) {
429
+ const idleGate = proactivelyCompactPiAtIdle(agent, agent.session);
430
+ if (idleGate && await idleGate === "failed") {
431
+ return { status: "deferred", deliveryId: record.deliveryId, reason: "Pi proactive compaction failed for the current session generation" };
432
+ }
433
+ }
396
434
  agent.submitting = true;
397
435
  if (!busy)
398
436
  agent.busy = true; // Reserve the turn before prompt() can yield.
@@ -432,7 +470,10 @@ export function createRuntimeHost(options) {
432
470
  if (!busy)
433
471
  agent.busy = false;
434
472
  const reason = error instanceof Error ? error.message : String(error);
435
- record.reason = reason;
473
+ if (record.errorCategory !== "context_window") {
474
+ record.reason = reason;
475
+ delete record.errorCategory;
476
+ }
436
477
  record.retryable = true;
437
478
  setRecord(agent, record, "pending");
438
479
  emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: record.deliveryId, messageId: record.messageId, status: "deferred", reason });
@@ -447,16 +488,41 @@ export function createRuntimeHost(options) {
447
488
  }
448
489
  };
449
490
  const retryPending = async (agent) => {
450
- reconcileExternalConsumption(agent);
451
- if (agent.submitting) {
452
- agent.retryAfterSubmit = true;
453
- return;
491
+ if (agent.retryPendingInFlight)
492
+ return agent.retryPendingInFlight;
493
+ const run = (async () => {
494
+ reconcileExternalConsumption(agent);
495
+ const proactive = agent.piProactiveCompaction
496
+ && agent.piProactiveCompactionGeneration === agent.generation
497
+ && agent.piProactiveCompactionSession === agent.session
498
+ ? agent.piProactiveCompaction : null;
499
+ if (proactive && await proactive === "failed")
500
+ return;
501
+ if (agent.submitting || agent.starting) {
502
+ agent.retryAfterSubmit = true;
503
+ return;
504
+ }
505
+ if (agent.adapter.id === "pi" && agent.piProactiveCompactionFailedGeneration === agent.generation)
506
+ return;
507
+ if (agent.busy || agent.turnInProgress || !agent.session)
508
+ return;
509
+ if (agent.adapter.id === "pi") {
510
+ const idleGate = proactivelyCompactPiAtIdle(agent, agent.session);
511
+ if (idleGate && await idleGate === "failed")
512
+ return;
513
+ }
514
+ const record = [...agent.records.values()].find((candidate) => candidate.status === "pending" || candidate.status === "submitting");
515
+ if (record)
516
+ await submit(agent, record, false);
517
+ })();
518
+ agent.retryPendingInFlight = run;
519
+ try {
520
+ await run;
521
+ }
522
+ finally {
523
+ if (agent.retryPendingInFlight === run)
524
+ agent.retryPendingInFlight = null;
454
525
  }
455
- if (agent.busy || agent.turnInProgress || !agent.session)
456
- return;
457
- const record = [...agent.records.values()].find((candidate) => candidate.status === "pending" || candidate.status === "submitting");
458
- if (record)
459
- await submit(agent, record, false);
460
526
  };
461
527
  const scheduleRecreate = (agent, reason) => {
462
528
  if (agent.stopped || agent.retryTimer || agent.starting || agent.session)
@@ -474,8 +540,12 @@ export function createRuntimeHost(options) {
474
540
  agent.retryTimer = null;
475
541
  if (agent.stopped)
476
542
  return;
477
- void ensureSession(agent).then(() => {
478
- return retryPending(agent);
543
+ void ensureSession(agent).then(async () => {
544
+ const session = agent.session;
545
+ const proactive = session ? proactivelyCompactPiAtIdle(agent, session) : null;
546
+ if (proactive && await proactive === "failed")
547
+ return;
548
+ await retryPending(agent);
479
549
  }).catch((error) => {
480
550
  const readiness = error instanceof RuntimePrerequisiteError ? error.readiness
481
551
  : classifyRuntimePrerequisite(agent.adapter.id, error);
@@ -560,6 +630,157 @@ export function createRuntimeHost(options) {
560
630
  })();
561
631
  agent.configurationRecovery = recovery;
562
632
  };
633
+ let recoverContextOverflowInternal;
634
+ const beginPiManualCompaction = (agent, session, record, nativeWillRetry = false) => {
635
+ if (agent.adapter.id !== "pi" || !session.compact || !agent.compactionBreaker)
636
+ return;
637
+ const existing = agent.compactionMachines.get(record.input.inputId);
638
+ if (existing) {
639
+ existing.agentEnd({ exactOverflow: true, willRetry: nativeWillRetry });
640
+ return;
641
+ }
642
+ const key = `${record.deliveryId}:${record.input.inputId}`;
643
+ const prior = agent.compactionBreaker.get(key);
644
+ if (prior && prior.manualAttempt >= 1 && ["manual_sent", "manual_ambiguous", "manual_failed", "native_failed"].includes(prior.state)) {
645
+ agent.busy = false;
646
+ agent.turnInProgress = false;
647
+ void recoverContextOverflowInternal(agent.config.agentId, key, "validated Pi compaction breaker state after restart", key)
648
+ .catch((error) => log("Pi restart fallback failed", String(error)));
649
+ return;
650
+ }
651
+ const machine = new PiCompactionRecoveryMachine({
652
+ breaker: agent.compactionBreaker, key, messageId: record.messageId, deliveryId: record.deliveryId,
653
+ inputId: record.input.inputId, sessionGeneration: agent.generation,
654
+ onAction: (action) => {
655
+ if (action === "retry_input") {
656
+ machine.retrySubmitted();
657
+ record.status = "pending";
658
+ record.retryable = true;
659
+ record.updatedAt = now();
660
+ persist(agent);
661
+ queueMicrotask(() => { if (!agent.stopped)
662
+ void submit(agent, record, false); });
663
+ }
664
+ else if (action === "fresh_session_fallback") {
665
+ agent.busy = false;
666
+ agent.turnInProgress = false;
667
+ const recoveryReason = machine.recordSnapshot.fallbackReason || "Pi compaction recovery fallback";
668
+ // Delivery proof must remain the exact provider-classified context error.
669
+ // Generic Pi recovery text belongs only in the breaker and logs.
670
+ if (record.errorCategory === "context_window" && typeof record.reason === "string") {
671
+ record.retryable = false;
672
+ setRecord(agent, record, "error");
673
+ if (!agent.compactionRecoveryInFlight.has(key)) {
674
+ agent.compactionRecoveryInFlight.add(key);
675
+ void recoverContextOverflowInternal(agent.config.agentId, key, recoveryReason, key)
676
+ .catch((error) => log("Pi context fallback failed", String(error)))
677
+ .finally(() => agent.compactionRecoveryInFlight.delete(key));
678
+ }
679
+ }
680
+ }
681
+ },
682
+ });
683
+ agent.compactionMachines.set(record.input.inputId, machine);
684
+ machine.agentEnd({ exactOverflow: true, willRetry: nativeWillRetry });
685
+ if (nativeWillRetry)
686
+ return;
687
+ machine.agentSettled();
688
+ const deadline = new Date(Date.now() + compactTimeoutMs).toISOString();
689
+ machine.manualCompactSent(deadline);
690
+ let compactSettled = false;
691
+ const compactTimer = setTimeout(() => {
692
+ if (compactSettled)
693
+ return;
694
+ compactSettled = true;
695
+ machine.compactTimeout();
696
+ }, compactTimeoutMs);
697
+ compactTimer.unref?.();
698
+ void session.compact().then(() => {
699
+ if (compactSettled)
700
+ return;
701
+ compactSettled = true;
702
+ clearTimeout(compactTimer);
703
+ machine.compactResponse({ success: true });
704
+ }).catch((error) => {
705
+ if (compactSettled)
706
+ return;
707
+ compactSettled = true;
708
+ clearTimeout(compactTimer);
709
+ const message = error instanceof Error ? error.message : String(error);
710
+ machine.compactResponse({ success: false, ambiguous: /process exited|process failed|session replaced|protocol/i.test(message) });
711
+ });
712
+ };
713
+ const recoverStalePiCompaction = async (agent) => {
714
+ if (!agent.stateStore)
715
+ return;
716
+ const breaker = agent.compactionBreaker;
717
+ if (!breaker)
718
+ return;
719
+ const stale = breaker.listNonTerminal().find((candidate) => candidate.state !== "native_succeeded");
720
+ if (!stale) {
721
+ const succeeded = breaker.listNonTerminal().find((candidate) => candidate.state === "native_succeeded");
722
+ if (succeeded)
723
+ breaker.transition(succeeded.key, {}, "closed");
724
+ return;
725
+ }
726
+ const record = [...agent.records.values()].find((candidate) => candidate.deliveryId === stale.deliveryId
727
+ || candidate.input.inputId === stale.inputId);
728
+ if (!record)
729
+ return;
730
+ const recoveryReason = stale.fallbackReason || "Pi compaction recovery resumed after restart";
731
+ if (record.errorCategory !== "context_window" || typeof record.reason !== "string")
732
+ return;
733
+ record.retryable = false;
734
+ setRecord(agent, record, "error");
735
+ breaker.forceFallback(stale.key, recoveryReason);
736
+ agent.busy = false;
737
+ agent.turnInProgress = false;
738
+ await recoverContextOverflowInternal(agent.config.agentId, stale.key, recoveryReason, stale.key);
739
+ };
740
+ const proactivelyCompactPiAtIdle = (agent, session) => {
741
+ if (agent.adapter.id !== "pi" || session.piPolicyManaged === false
742
+ || !session.getContextUsage || !session.compact)
743
+ return null;
744
+ if (agent.session !== session || agent.stopped || agent.busy || agent.turnInProgress || agent.submitting || agent.starting
745
+ || agent.compactionRecoveryInFlight.size > 0
746
+ || agent.compactionMachines.size > 0 || agent.piOverflowCompactionFailed.size > 0)
747
+ return null;
748
+ if (agent.piProactiveCompactionFailedGeneration === agent.generation)
749
+ return null;
750
+ const generation = agent.generation;
751
+ if (agent.piProactiveCompaction && agent.piProactiveCompactionGeneration === generation
752
+ && agent.piProactiveCompactionSession === session)
753
+ return agent.piProactiveCompaction;
754
+ const task = (async () => {
755
+ const before = await session.getContextUsage();
756
+ if (!before || agent.session !== session || agent.stopped || agent.busy || agent.turnInProgress || agent.submitting
757
+ || agent.compactionRecoveryInFlight.size > 0 || agent.compactionMachines.size > 0
758
+ || agent.piOverflowCompactionFailed.size > 0)
759
+ return "noop";
760
+ if (!isPiNativeCompactionRequired(before.tokens, before.contextWindow))
761
+ return "noop";
762
+ await session.compact();
763
+ const after = await session.getContextUsage();
764
+ if (!after || after.contextWindow !== before.contextWindow || after.tokens >= before.tokens) {
765
+ throw new Error("Pi proactive compaction did not reduce the verified context usage");
766
+ }
767
+ return "succeeded";
768
+ })();
769
+ const tracked = task.catch((error) => {
770
+ agent.piProactiveCompactionFailedGeneration = generation;
771
+ emit({ type: "agent-status", agentId: agent.config.agentId, status: "error",
772
+ error: "Pi proactive compaction failed; the current session generation is degraded" });
773
+ log("Pi proactive compaction failed", String(error));
774
+ return "failed";
775
+ }).finally(() => {
776
+ if (agent.piProactiveCompaction === tracked)
777
+ agent.piProactiveCompaction = null;
778
+ });
779
+ agent.piProactiveCompactionGeneration = generation;
780
+ agent.piProactiveCompactionSession = session;
781
+ agent.piProactiveCompaction = tracked;
782
+ return tracked;
783
+ };
563
784
  const observe = (agent, session, event) => {
564
785
  if (agent.session !== session)
565
786
  return; // Ignore late output from a replaced child.
@@ -568,11 +789,38 @@ export function createRuntimeHost(options) {
568
789
  if (event.type !== "turn-end")
569
790
  telemetry?.runtimeEvent(agent.config.agentId, event);
570
791
  emit({ type: "runtime", agentId: agent.config.agentId, event });
792
+ if (event.type === "runtime-observation") {
793
+ if (event.phase === "compaction_start" && event.reason === "overflow" && event.inputId) {
794
+ const overflowRecord = [...agent.records.values()].find((candidate) => candidate.input.inputId === event.inputId);
795
+ if (overflowRecord && !agent.compactionMachines.has(event.inputId))
796
+ beginPiManualCompaction(agent, session, overflowRecord, true);
797
+ }
798
+ const candidates = [...agent.compactionMachines.values()].filter((machine) => machine.recordSnapshot.sessionGeneration === agent.generation
799
+ && (!event.inputId || machine.recordSnapshot.inputId === event.inputId));
800
+ const machines = event.inputId ? candidates : candidates.length === 1 ? candidates : [];
801
+ for (const machine of machines) {
802
+ if (event.phase === "compaction_start" && event.reason === "overflow")
803
+ machine.compactionStart({ reason: "overflow" });
804
+ if (event.phase === "compaction_end" && event.reason === "manual") {
805
+ machine.compactionEnd({ reason: "manual", success: event.success === true, willRetry: event.willRetry === true });
806
+ }
807
+ if (event.phase === "compaction_end" && event.reason === "overflow") {
808
+ machine.compactionEnd({ reason: "overflow", success: event.success === true, willRetry: event.willRetry === true });
809
+ }
810
+ if (event.phase === "settled")
811
+ machine.agentSettled();
812
+ }
813
+ if (event.phase === "compaction_end" && event.reason === "overflow" && event.success !== true) {
814
+ for (const record of agent.records.values()) {
815
+ if (isActiveDelivery(record.status))
816
+ agent.piOverflowCompactionFailed.add(record.input.inputId);
817
+ }
818
+ }
819
+ }
571
820
  if (event.type === "session-init") {
572
821
  agent.config.sessionId = event.sessionId;
573
822
  emit({ type: "session", agentId: agent.config.agentId, runtime: agent.adapter.id, sessionId: event.sessionId, launchId: agent.launchId,
574
823
  ...(event.model ? { model: event.model } : {}), ...(event.reasoningEffort ? { reasoningEffort: event.reasoningEffort } : {}) });
575
- void retryPending(agent);
576
824
  }
577
825
  else if (event.type === "turn-start") {
578
826
  agent.busy = true;
@@ -583,6 +831,13 @@ export function createRuntimeHost(options) {
583
831
  emit({ type: "activity", agentId: agent.config.agentId, activity: "working", activityKind: "working", detailKind: "turn_started" });
584
832
  }
585
833
  else if (event.type === "turn-end") {
834
+ for (const [inputId, machine] of agent.compactionMachines) {
835
+ if (machine.recordSnapshot.sessionGeneration !== agent.generation)
836
+ continue;
837
+ machine.close();
838
+ if (["closed", "fallback_committed"].includes(machine.state))
839
+ agent.compactionMachines.delete(inputId);
840
+ }
586
841
  const recoveredAuthentication = agent.authFailureActive && agent.turnInProgress
587
842
  && agent.turnHadAuthenticatedOutput && !agent.turnHadFailure;
588
843
  agent.turnInProgress = false;
@@ -601,7 +856,11 @@ export function createRuntimeHost(options) {
601
856
  };
602
857
  emit({ type: "agent-status", agentId: agent.config.agentId, status: "active", readiness: agent.readiness });
603
858
  }
604
- void retryPending(agent);
859
+ const proactive = proactivelyCompactPiAtIdle(agent, session);
860
+ void (proactive ?? Promise.resolve("noop")).then((outcome) => {
861
+ if (outcome !== "failed")
862
+ return retryPending(agent);
863
+ });
605
864
  }
606
865
  else if (event.type === "activity") {
607
866
  if (agent.turnInProgress && event.activity !== "internal")
@@ -615,6 +874,34 @@ export function createRuntimeHost(options) {
615
874
  if (!record || record.status === "consumed" || record.status === "error")
616
875
  return;
617
876
  agent.turnHadFailure = true;
877
+ if (agent.adapter.id === "pi" && event.errorCategory === "context_window" && event.willRetry === true && session.compact) {
878
+ beginPiManualCompaction(agent, session, record, true);
879
+ return;
880
+ }
881
+ if (agent.adapter.id === "pi" && event.errorCategory === "context_window" && event.willRetry !== true && session.compact) {
882
+ record.reason = event.message;
883
+ record.retryable = false;
884
+ record.errorCategory = "context_window";
885
+ setRecord(agent, record, "error");
886
+ if (agent.piOverflowCompactionFailed.has(record.input.inputId)) {
887
+ agent.busy = false;
888
+ agent.turnInProgress = false;
889
+ const recoveryKey = `${record.deliveryId}:${record.input.inputId}`;
890
+ if (!agent.compactionRecoveryInFlight.has(recoveryKey)) {
891
+ agent.compactionRecoveryInFlight.add(recoveryKey);
892
+ void recoverContextOverflowInternal(agent.config.agentId, recoveryKey, "native Pi overflow compaction failed", recoveryKey)
893
+ .catch((error) => log("Pi native compaction fallback failed", String(error)))
894
+ .finally(() => agent.compactionRecoveryInFlight.delete(recoveryKey));
895
+ }
896
+ return;
897
+ }
898
+ // Pi emits this terminal error only after agent_settled; release the
899
+ // normal turn reservation before the one authorized recovery action.
900
+ agent.busy = false;
901
+ agent.turnInProgress = false;
902
+ beginPiManualCompaction(agent, session, record);
903
+ return;
904
+ }
618
905
  if (event.willRetry) {
619
906
  emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: record.deliveryId,
620
907
  messageId: record.messageId, status: "deferred", reason: event.message });
@@ -625,6 +912,13 @@ export function createRuntimeHost(options) {
625
912
  agent.busy = false;
626
913
  record.reason = event.message;
627
914
  record.retryable = event.retryable;
915
+ const classifiedCategory = event.errorCategory ?? classifyStrictProviderError(buildStrictProviderErrorInput({
916
+ message: event.message, errorCategory: event.errorCategory, upstream: event.upstream,
917
+ }));
918
+ if (classifiedCategory)
919
+ record.errorCategory = classifiedCategory;
920
+ else
921
+ delete record.errorCategory;
628
922
  const finalRecord = setRecord(agent, record, event.retryable ? "pending" : "error");
629
923
  if (finalRecord.status !== "consumed")
630
924
  emit({ type: "delivery", agentId: agent.config.agentId,
@@ -711,6 +1005,132 @@ export function createRuntimeHost(options) {
711
1005
  });
712
1006
  return agent.starting;
713
1007
  };
1008
+ recoverContextOverflowInternal = async (agentId, deliveryKey, reason, breakerKey) => {
1009
+ const agent = managed.get(agentId);
1010
+ if (!agent || agent.stopped)
1011
+ throw new RuntimeSessionRecoveryError("unknown_agent", `unknown runtime Agent: ${agentId}`);
1012
+ if (agent.adapter.id !== "pi")
1013
+ throw new RuntimeSessionRecoveryError("recovery_unavailable", "Pi context fallback is unavailable for this runtime");
1014
+ const oldSession = agent.session;
1015
+ if (!oldSession || agent.busy || agent.submitting || agent.starting) {
1016
+ throw new RuntimeSessionRecoveryError("agent_busy", `Agent ${agentId} is not idle for Pi context fallback`);
1017
+ }
1018
+ const record = [...agent.records.values()].find((candidate) => candidate.deliveryId === deliveryKey || candidate.input.inputId === deliveryKey || `${candidate.deliveryId}:${candidate.input.inputId}` === deliveryKey);
1019
+ if (!record)
1020
+ throw new RuntimeSessionRecoveryError("recovery_refused", "Pi context fallback delivery identity is unavailable");
1021
+ const probeEnv = runtimeEnv(agent.config, `${agent.launchId}:pi-context-fallback`);
1022
+ await assertOfficialCliReady(agent.config, probeEnv);
1023
+ const readiness = agent.adapter.probe ? await agent.adapter.probe({ agentId: agent.config.agentId,
1024
+ workspaceDir: agent.config.workspaceDir, stateDir: agent.config.stateDir, env: probeEnv }) : { runtime: "pi", state: "ready" };
1025
+ if (readiness.state !== "ready")
1026
+ throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", "Pi context fallback readiness failed");
1027
+ const standingPrompt = options.promptBuilder.build({
1028
+ agentId: agent.config.agentId, name: agent.config.displayName || agent.config.name,
1029
+ description: agent.config.description || "", runtime: agent.adapter.id, cli: agentCliPromptCapabilities("larkin"),
1030
+ });
1031
+ let fresh;
1032
+ try {
1033
+ fresh = await agent.adapter.createSession({ agentId: agent.config.agentId, model: agent.config.model,
1034
+ reasoningEffort: agent.config.effort || null, workspaceDir: agent.config.workspaceDir, stateDir: agent.config.stateDir,
1035
+ resumeSessionId: null, standingPrompt, env: probeEnv });
1036
+ }
1037
+ catch {
1038
+ throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", "Pi fresh fallback session could not be staged");
1039
+ }
1040
+ if (agent.session !== oldSession || agent.busy || agent.submitting || agent.starting || agent.stopped) {
1041
+ await fresh.close("Pi context fallback commit precondition changed").catch(() => { });
1042
+ throw new RuntimeSessionRecoveryError("agent_busy", `Agent ${agentId} changed during Pi context fallback`);
1043
+ }
1044
+ let unsubscribeFresh = null;
1045
+ try {
1046
+ unsubscribeFresh = fresh.subscribe((next) => observe(agent, fresh, next));
1047
+ }
1048
+ catch {
1049
+ await fresh.close("Pi context fallback subscription failed").catch(() => { });
1050
+ throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", "Pi context fallback subscription failed");
1051
+ }
1052
+ const oldSessionId = oldSession.sessionId;
1053
+ const oldGeneration = agent.generation;
1054
+ const oldLaunchId = agent.launchId;
1055
+ const oldConfigSessionId = agent.config.sessionId;
1056
+ const oldReadiness = agent.readiness;
1057
+ const oldDisabledReason = agent.disabledReason;
1058
+ const oldRecord = { ...record, input: { ...record.input } };
1059
+ let durableRollback = null;
1060
+ let rearmedCount = 1;
1061
+ let remainingPendingCount = 1;
1062
+ const commitSwap = (messageIds, rollback) => {
1063
+ if (agent.session !== oldSession || agent.busy || agent.submitting || agent.starting || agent.stopped) {
1064
+ throw new RuntimeSessionRecoveryError("agent_busy", `Agent ${agentId} changed during Pi context fallback`);
1065
+ }
1066
+ durableRollback = rollback ?? null;
1067
+ if (breakerKey && agent.compactionBreaker)
1068
+ agent.compactionBreaker.forceFallbackInTransaction(breakerKey, reason);
1069
+ agent.generation += 1;
1070
+ agent.launchId = crypto.randomUUID();
1071
+ agent.config.sessionId = null;
1072
+ agent.session = fresh;
1073
+ agent.readiness = readiness;
1074
+ agent.disabledReason = null;
1075
+ for (const candidate of agent.records.values()) {
1076
+ if (!messageIds.includes(candidate.messageId))
1077
+ continue;
1078
+ candidate.status = "pending";
1079
+ candidate.retryable = true;
1080
+ candidate.updatedAt = now();
1081
+ }
1082
+ };
1083
+ try {
1084
+ if (agent.stateStore?.rearmContextOverflow) {
1085
+ const rearmed = agent.stateStore.rearmContextOverflow((messageIds, rollback) => commitSwap(messageIds, rollback), {
1086
+ messageId: record.messageId, deliveryId: record.deliveryId, inputId: record.input.inputId,
1087
+ });
1088
+ rearmedCount = rearmed.rearmedCount;
1089
+ remainingPendingCount = rearmed.remainingPendingCount;
1090
+ }
1091
+ else {
1092
+ commitSwap([record.messageId]);
1093
+ persist(agent);
1094
+ }
1095
+ }
1096
+ catch (error) {
1097
+ try {
1098
+ const rollback = durableRollback;
1099
+ if (rollback)
1100
+ rollback();
1101
+ }
1102
+ catch { /* preserve primary error */ }
1103
+ agent.generation = oldGeneration;
1104
+ agent.launchId = oldLaunchId;
1105
+ agent.config.sessionId = oldConfigSessionId;
1106
+ agent.session = oldSession;
1107
+ agent.readiness = oldReadiness;
1108
+ agent.disabledReason = oldDisabledReason;
1109
+ Object.assign(record, oldRecord);
1110
+ try {
1111
+ unsubscribeFresh?.();
1112
+ }
1113
+ catch { /* preserve primary failure */ }
1114
+ await fresh.close("Pi context fallback rolled back").catch(() => { });
1115
+ throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", error instanceof Error ? error.message : "Pi context fallback commit failed");
1116
+ }
1117
+ if (fresh.sessionId) {
1118
+ agent.config.sessionId = fresh.sessionId;
1119
+ emit({ type: "session", agentId, runtime: agent.adapter.id, sessionId: fresh.sessionId, launchId: agent.launchId,
1120
+ ...(fresh.effectiveModel ? { model: fresh.effectiveModel } : {}),
1121
+ ...(fresh.effectiveReasoningEffort ? { reasoningEffort: fresh.effectiveReasoningEffort } : {}) });
1122
+ }
1123
+ emit({ type: "agent-status", agentId, status: "active", readiness });
1124
+ await oldSession.close("Pi context fallback committed").catch((error) => log("previous Pi session close after fallback failed", String(error)));
1125
+ const proactive = proactivelyCompactPiAtIdle(agent, fresh);
1126
+ void (proactive ?? Promise.resolve("noop")).then((outcome) => {
1127
+ if (outcome !== "failed")
1128
+ return retryPending(agent);
1129
+ });
1130
+ return { generationChanged: true, sessionChanged: oldSessionId !== fresh.sessionId, turns: 0,
1131
+ runtimeReady: true, pendingCount: remainingPendingCount, rearmedCount,
1132
+ replayStatus: remainingPendingCount > 0 ? "pending" : "consumed", sessionId: fresh.sessionId };
1133
+ };
714
1134
  return {
715
1135
  async probe(config) {
716
1136
  const adapter = options.adapterFor(config.runtime);
@@ -885,6 +1305,183 @@ export function createRuntimeHost(options) {
885
1305
  return { generationChanged: true, sessionChanged: oldSessionId !== fresh.sessionId, turns: 0,
886
1306
  runtimeReady: true, pendingCount: 0, sessionId: fresh.sessionId };
887
1307
  },
1308
+ async recoverSession(agentId, reason) {
1309
+ if (reason !== "context-overflow")
1310
+ throw new RuntimeSessionRecoveryError("recovery_refused", "unsupported recovery reason");
1311
+ const agent = managed.get(agentId);
1312
+ if (agent && agent.adapter.id !== "pi")
1313
+ throw new RuntimeSessionRecoveryError("recovery_unavailable", "context-overflow recovery is available only for Pi Runtime");
1314
+ if (!agent || agent.stopped)
1315
+ throw new RuntimeSessionRecoveryError("unknown_agent", `unknown runtime Agent: ${agentId}`);
1316
+ if (!agent.stateStore?.rearmContextOverflow) {
1317
+ throw new RuntimeSessionRecoveryError("recovery_unavailable", "canonical Inbox recovery is unavailable");
1318
+ }
1319
+ if (agent.busy || agent.turnInProgress || agent.submitting || agent.starting) {
1320
+ throw new RuntimeSessionRecoveryError("agent_busy", `Agent ${agentId} is not idle`);
1321
+ }
1322
+ const oldSession = agent.session;
1323
+ if (!oldSession)
1324
+ throw new RuntimeSessionRecoveryError("agent_busy", `Agent ${agentId} Runtime session is unavailable`);
1325
+ const oldSessionId = oldSession.sessionId;
1326
+ if (agent.stateStore.readNdjson) {
1327
+ const pendingRows = agent.stateStore.withInboxTransaction(() => agent.stateStore.readNdjson("inbox"));
1328
+ if (pendingRows.length === 0) {
1329
+ throw new RuntimeSessionRecoveryError("recovery_refused", "context-window recovery has no retained Inbox backlog");
1330
+ }
1331
+ const messageIds = new Set(pendingRows.flatMap((row) => {
1332
+ const messageId = row && typeof row === "object" && !Array.isArray(row)
1333
+ ? row.message_id : undefined;
1334
+ return typeof messageId === "string" && messageId ? [messageId] : [];
1335
+ }));
1336
+ const persisted = agent.stateStore.readJson("runtimeDeliveries", { version: 1, records: [] });
1337
+ if (Array.isArray(persisted.records) && persisted.records.some((record) => messageIds.has(record.messageId) && record.status !== "error")) {
1338
+ throw new RuntimeSessionRecoveryError("recovery_refused", "context-window recovery is already staged or completed");
1339
+ }
1340
+ }
1341
+ const probeEnv = runtimeEnv(agent.config, `${agent.launchId}:context-overflow-recovery`);
1342
+ let readiness;
1343
+ try {
1344
+ await assertOfficialCliReady(agent.config, probeEnv);
1345
+ readiness = agent.adapter.probe ? await agent.adapter.probe({ agentId: agent.config.agentId,
1346
+ workspaceDir: agent.config.workspaceDir, stateDir: agent.config.stateDir,
1347
+ env: { LARKIN_PI_COMMAND: process.env.LARKIN_PI_COMMAND, LARKIN_CODEX_COMMAND: process.env.LARKIN_CODEX_COMMAND,
1348
+ LARKIN_CLAUDE_COMMAND: process.env.LARKIN_CLAUDE_COMMAND, ...probeEnv } })
1349
+ : { runtime: agent.adapter.id, state: "ready" };
1350
+ }
1351
+ catch (error) {
1352
+ if (error instanceof RuntimePrerequisiteError)
1353
+ throw error;
1354
+ throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", "Runtime readiness probe failed");
1355
+ }
1356
+ if (readiness.state !== "ready")
1357
+ throw new RuntimePrerequisiteError(readiness);
1358
+ const standingPrompt = options.promptBuilder.build({
1359
+ agentId: agent.config.agentId, name: agent.config.displayName || agent.config.name,
1360
+ description: agent.config.description || "", runtime: agent.adapter.id,
1361
+ cli: agentCliPromptCapabilities("larkin"),
1362
+ });
1363
+ let fresh;
1364
+ try {
1365
+ fresh = await agent.adapter.createSession({
1366
+ agentId: agent.config.agentId, model: agent.config.model, reasoningEffort: agent.config.effort || null,
1367
+ workspaceDir: agent.config.workspaceDir, stateDir: agent.config.stateDir,
1368
+ resumeSessionId: null, standingPrompt, env: probeEnv,
1369
+ });
1370
+ }
1371
+ catch {
1372
+ throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", "fresh Runtime session could not be staged");
1373
+ }
1374
+ let unsubscribeFresh = null;
1375
+ try {
1376
+ unsubscribeFresh = fresh.subscribe((event) => observe(agent, fresh, event));
1377
+ }
1378
+ catch {
1379
+ await fresh.close("context-window recovery subscription failed").catch(() => { });
1380
+ throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", "fresh Runtime session subscription failed");
1381
+ }
1382
+ const oldGeneration = agent.generation;
1383
+ const oldLaunchId = agent.launchId;
1384
+ const oldConfigSessionId = agent.config.sessionId;
1385
+ const oldReadiness = agent.readiness;
1386
+ const oldDisabledReason = agent.disabledReason;
1387
+ const oldRecords = new Map([...agent.records.entries()].map(([id, record]) => [id, { ...record, input: { ...record.input } }]));
1388
+ const cleanupFresh = async (reason) => {
1389
+ const unsubscribe = unsubscribeFresh;
1390
+ unsubscribeFresh = null;
1391
+ try {
1392
+ unsubscribe?.();
1393
+ }
1394
+ catch { /* close still owns staged-session cleanup */ }
1395
+ await fresh.close(reason).catch(() => { });
1396
+ };
1397
+ const restoreRuntime = () => {
1398
+ agent.session = oldSession;
1399
+ agent.generation = oldGeneration;
1400
+ agent.launchId = oldLaunchId;
1401
+ agent.config.sessionId = oldConfigSessionId;
1402
+ agent.readiness = oldReadiness;
1403
+ agent.disabledReason = oldDisabledReason;
1404
+ agent.records = new Map(oldRecords);
1405
+ agent.byMessage = new Map([...oldRecords.values()].map((record) => [record.messageId, record.deliveryId]));
1406
+ };
1407
+ let recoveryProjectionEmitted = false;
1408
+ const compensateProjection = () => {
1409
+ if (!recoveryProjectionEmitted)
1410
+ return;
1411
+ try {
1412
+ emit({ type: "session", agentId, runtime: agent.adapter.id, sessionId: oldSessionId, launchId: oldLaunchId });
1413
+ }
1414
+ catch { /* preserve the original listener failure */ }
1415
+ try {
1416
+ emit({ type: "agent-status", agentId, status: "active", ...(oldReadiness ? { readiness: oldReadiness } : {}) });
1417
+ }
1418
+ catch { /* preserve the original listener failure */ }
1419
+ };
1420
+ let durableRollback = null;
1421
+ try {
1422
+ const rearmed = agent.stateStore.rearmContextOverflow((messageIds, rollback) => {
1423
+ // This callback runs under the canonical Inbox lock. All potentially
1424
+ // racing checks above are synchronous from this process, while the
1425
+ // state-store method rechecks every row and delivery on disk.
1426
+ if (agent.session !== oldSession || agent.busy || agent.turnInProgress || agent.submitting || agent.starting || agent.stopped) {
1427
+ throw new RuntimeSessionRecoveryError("agent_busy", `Agent ${agentId} changed during recovery`);
1428
+ }
1429
+ durableRollback = rollback;
1430
+ agent.generation += 1;
1431
+ agent.launchId = crypto.randomUUID();
1432
+ agent.config.sessionId = null;
1433
+ agent.session = fresh;
1434
+ agent.readiness = readiness;
1435
+ agent.disabledReason = null;
1436
+ for (const record of agent.records.values()) {
1437
+ if (!messageIds.includes(record.messageId))
1438
+ continue;
1439
+ record.status = "pending";
1440
+ record.retryable = true;
1441
+ record.updatedAt = now();
1442
+ }
1443
+ });
1444
+ recoveryProjectionEmitted = true;
1445
+ if (fresh.sessionId) {
1446
+ agent.config.sessionId = fresh.sessionId;
1447
+ emit({ type: "session", agentId, runtime: agent.adapter.id, sessionId: fresh.sessionId, launchId: agent.launchId,
1448
+ ...(fresh.effectiveModel ? { model: fresh.effectiveModel } : {}),
1449
+ ...(fresh.effectiveReasoningEffort ? { reasoningEffort: fresh.effectiveReasoningEffort } : {}) });
1450
+ }
1451
+ emit({ type: "agent-status", agentId, status: "active", readiness });
1452
+ durableRollback = null;
1453
+ await oldSession.close("context-window recovery committed")
1454
+ .catch((error) => log("previous runtime close after context-window recovery failed", String(error)));
1455
+ const proactive = proactivelyCompactPiAtIdle(agent, fresh);
1456
+ void (proactive ?? Promise.resolve("noop")).then((outcome) => {
1457
+ if (outcome !== "failed")
1458
+ return retryPending(agent);
1459
+ });
1460
+ return { generationChanged: true, sessionChanged: oldSessionId !== fresh.sessionId, turns: 0,
1461
+ runtimeReady: true, pendingCount: rearmed.remainingPendingCount, rearmedCount: rearmed.rearmedCount,
1462
+ replayStatus: rearmed.remainingPendingCount > 0 ? "pending" : "consumed", sessionId: fresh.sessionId };
1463
+ }
1464
+ catch (error) {
1465
+ try {
1466
+ const rollback = durableRollback;
1467
+ if (rollback)
1468
+ rollback();
1469
+ }
1470
+ catch { /* preserve the original failure */ }
1471
+ restoreRuntime();
1472
+ compensateProjection();
1473
+ await cleanupFresh("context-window recovery not committed");
1474
+ if (error instanceof RuntimeSessionRecoveryError)
1475
+ throw error;
1476
+ if (typeof error.code === "string") {
1477
+ throw new RuntimeSessionRecoveryError("recovery_refused", error instanceof Error ? error.message : "context-window recovery was refused");
1478
+ }
1479
+ throw new RuntimeSessionRecoveryError("recovery_staged_not_committed", "context-window recovery was not committed");
1480
+ }
1481
+ },
1482
+ async recoverContextOverflow(agentId, deliveryKey, reason) {
1483
+ return recoverContextOverflowInternal(agentId, deliveryKey, reason);
1484
+ },
888
1485
  async start(configs) {
889
1486
  let activeCount = 0;
890
1487
  let recoveringCount = 0;
@@ -927,11 +1524,19 @@ export function createRuntimeHost(options) {
927
1524
  : readDeliveryFile(config.stateDir ? path.join(config.stateDir, "runtime-deliveries.json") : null);
928
1525
  const agent = { config, adapter: options.adapterFor(config.runtime), session: null,
929
1526
  launchId: crypto.randomUUID(), busy: false, submitting: false, starting: null, retryAfterSubmit: false,
1527
+ retryPendingInFlight: null,
930
1528
  records: new Map(persisted.records.map((record) => [record.deliveryId, record])),
931
1529
  byMessage: new Map(persisted.records.map((record) => [record.messageId, record.deliveryId])),
932
1530
  generation: 0, poller: null, retryTimer: null, recreateAttempts: 0,
933
1531
  stabilityTimer: null, recreateReason: null, stopped: false, disabledReason: null,
934
- configurationRecovery: null, stateStore, readiness: null,
1532
+ configurationRecovery: null, stateStore,
1533
+ compactionBreaker: config.runtime === "pi" && stateStore
1534
+ ? new PiCompactionBreaker(config.stateDir ?? path.join(config.workspaceDir, ".larkin", "agents", config.agentId), {
1535
+ withLock: (operation) => stateStore.withInboxTransaction(operation),
1536
+ }) : undefined,
1537
+ compactionMachines: new Map(), compactionRecoveryInFlight: new Set(), piOverflowCompactionFailed: new Set(),
1538
+ piProactiveCompaction: null, piProactiveCompactionGeneration: null, piProactiveCompactionSession: null,
1539
+ piProactiveCompactionFailedGeneration: null, readiness: null,
935
1540
  turnInProgress: false, turnHadFailure: false, turnHadAuthenticatedOutput: false, authFailureActive: false };
936
1541
  const startupConsumed = [];
937
1542
  const startupQuarantined = [];
@@ -963,8 +1568,13 @@ export function createRuntimeHost(options) {
963
1568
  emitConsumed(agent, [...startupConsumed, ...persist(agent)]);
964
1569
  try {
965
1570
  await ensureSession(agent);
1571
+ await recoverStalePiCompaction(agent);
1572
+ const startupSession = agent.session;
1573
+ const proactive = startupSession ? proactivelyCompactPiAtIdle(agent, startupSession) : null;
1574
+ const proactiveOutcome = proactive ? await proactive : "noop";
966
1575
  activeCount += 1;
967
- await retryPending(agent);
1576
+ if (proactiveOutcome !== "failed")
1577
+ await retryPending(agent);
968
1578
  }
969
1579
  catch (error) {
970
1580
  const reason = error instanceof Error ? error.message : String(error);