@animalabs/agent-framework 0.6.2 → 0.6.4

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 (64) hide show
  1. package/dist/src/agent.d.ts +3 -1
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +7 -2
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +60 -1
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +261 -20
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/index.d.ts +1 -0
  10. package/dist/src/index.d.ts.map +1 -1
  11. package/dist/src/index.js +1 -0
  12. package/dist/src/index.js.map +1 -1
  13. package/dist/src/modules/discord/index.d.ts +1 -0
  14. package/dist/src/modules/discord/index.d.ts.map +1 -1
  15. package/dist/src/modules/discord/index.js +4 -1
  16. package/dist/src/modules/discord/index.js.map +1 -1
  17. package/dist/src/modules/discord/types.d.ts +2 -0
  18. package/dist/src/modules/discord/types.d.ts.map +1 -1
  19. package/dist/src/modules/health/index.d.ts +2 -0
  20. package/dist/src/modules/health/index.d.ts.map +1 -1
  21. package/dist/src/modules/health/index.js +15 -4
  22. package/dist/src/modules/health/index.js.map +1 -1
  23. package/dist/src/timezone.d.ts +14 -0
  24. package/dist/src/timezone.d.ts.map +1 -0
  25. package/dist/src/timezone.js +74 -0
  26. package/dist/src/timezone.js.map +1 -0
  27. package/dist/src/types/agent.d.ts +5 -2
  28. package/dist/src/types/agent.d.ts.map +1 -1
  29. package/dist/src/types/framework.d.ts +46 -0
  30. package/dist/src/types/framework.d.ts.map +1 -1
  31. package/dist/src/types/trace.d.ts +9 -0
  32. package/dist/src/types/trace.d.ts.map +1 -1
  33. package/dist/test/agent-cache-ttl.test.js +18 -2
  34. package/dist/test/agent-cache-ttl.test.js.map +1 -1
  35. package/dist/test/health-snapshot.test.d.ts +2 -0
  36. package/dist/test/health-snapshot.test.d.ts.map +1 -0
  37. package/dist/test/health-snapshot.test.js +74 -0
  38. package/dist/test/health-snapshot.test.js.map +1 -0
  39. package/dist/test/inference-failure-observability.test.js +5 -0
  40. package/dist/test/inference-failure-observability.test.js.map +1 -1
  41. package/dist/test/maintenance-timer.test.d.ts +2 -0
  42. package/dist/test/maintenance-timer.test.d.ts.map +1 -0
  43. package/dist/test/maintenance-timer.test.js +114 -0
  44. package/dist/test/maintenance-timer.test.js.map +1 -0
  45. package/dist/test/ops-alert.test.d.ts +2 -0
  46. package/dist/test/ops-alert.test.d.ts.map +1 -0
  47. package/dist/test/ops-alert.test.js +152 -0
  48. package/dist/test/ops-alert.test.js.map +1 -0
  49. package/dist/test/timezone.test.d.ts +2 -0
  50. package/dist/test/timezone.test.d.ts.map +1 -0
  51. package/dist/test/timezone.test.js +18 -0
  52. package/dist/test/timezone.test.js.map +1 -0
  53. package/dist/tsconfig.tsbuildinfo +1 -1
  54. package/package.json +1 -1
  55. package/src/agent.ts +8 -3
  56. package/src/framework.ts +307 -22
  57. package/src/index.ts +1 -0
  58. package/src/modules/discord/index.ts +4 -1
  59. package/src/modules/discord/types.ts +3 -0
  60. package/src/modules/health/index.ts +20 -4
  61. package/src/timezone.ts +90 -0
  62. package/src/types/agent.ts +6 -2
  63. package/src/types/framework.ts +51 -0
  64. package/src/types/trace.ts +16 -0
@@ -21,6 +21,7 @@ import { CheckpointManager } from './mcpl/checkpoint-manager.js';
21
21
  import { isToolAllowed } from './mcpl/tool-policy.js';
22
22
  import { EventGate } from './gate/event-gate.js';
23
23
  import { UsageTracker } from './usage/usage-tracker.js';
24
+ import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
24
25
  const FRAMEWORK_STATE_ID = 'framework/state';
25
26
  const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
26
27
  const INFERENCE_LOG_ID = 'framework/inference-log';
@@ -66,6 +67,10 @@ class DefaultErrorPolicy {
66
67
  }
67
68
  /** Default sync interval in milliseconds */
68
69
  const DEFAULT_SYNC_INTERVAL_MS = 1000;
70
+ /** Poll pending context-strategy maintenance independently of user activity. */
71
+ const DEFAULT_MAINTENANCE_INTERVAL_MS = 5000;
72
+ /** Bound one pass so a large backlog yields to inference and other agents. */
73
+ const MAINTENANCE_TICKS_PER_PASS = 8;
69
74
  /**
70
75
  * Extract fields the EventGate cares about from a ProcessEvent. The set of
71
76
  * event variants that carry `content`/`mount`/`paths`/`metadata` is open-ended
@@ -128,6 +133,12 @@ export class AgentFramework {
128
133
  traceListeners = [];
129
134
  syncIntervalMs;
130
135
  syncTimer = null;
136
+ maintenanceIntervalMs;
137
+ maintenanceTimer = null;
138
+ maintenancePass = null;
139
+ maintenanceRunId = 0;
140
+ currentMaintenanceRun = null;
141
+ maintenanceHistory = [];
131
142
  /** Last time we console-warned about stale (busy-requeued) inference requests, per agent. */
132
143
  staleWarnAt = new Map();
133
144
  /** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
@@ -142,6 +153,19 @@ export class AgentFramework {
142
153
  /** N consecutive failed inferences ⇒ the agent is treated as hard-down and
143
154
  * escalated loudly to stderr. */
144
155
  inferenceFailureEscalationThreshold = 3;
156
+ /** Per-(agent,kind) timestamp of the last ops webhook post. Throttles
157
+ * opsAlert() so a persistent failure re-posts once per cooldown window
158
+ * instead of on every occurrence. */
159
+ opsAlertLastSent = new Map();
160
+ /** Cooldown between webhook posts for the same (agent, kind). */
161
+ opsAlertCooldownMs = 15 * 60_000;
162
+ /** Per-agent refusal bookkeeping for observability — exposed via
163
+ * healthSnapshot() and mirrored to failures.log / ops alerts. */
164
+ refusalStats = new Map();
165
+ /** Per-agent count of consecutive refusals (reset on any non-refusal
166
+ * completion). Distinct from refusalRewinds, which budgets the auto-rewind
167
+ * loop — this one drives ops alerting. */
168
+ refusalStreak = new Map();
145
169
  /** Per-agent count of consecutive refusal-driven rewinds in the current turn
146
170
  * chain (reset when a turn completes without a refusal). Bounds the auto
147
171
  * rewind loop — see refusalHandling + rewindTriggeringTurn. */
@@ -211,15 +235,19 @@ export class AgentFramework {
211
235
  eventGate = null;
212
236
  // Session-level token usage tracking (always-on)
213
237
  usageTracker;
214
- constructor(store, ownsStore, membrane, inferencePolicy, errorPolicy, syncIntervalMs, processLoggingPersist, processLoggingBroadcast) {
238
+ /** Presentation-only wall-clock zone; persistence remains UTC/epoch. */
239
+ timeZone;
240
+ constructor(store, ownsStore, membrane, inferencePolicy, errorPolicy, syncIntervalMs, maintenanceIntervalMs, processLoggingPersist, processLoggingBroadcast, timeZone) {
215
241
  this.store = store;
216
242
  this.ownsStore = ownsStore;
217
243
  this.membrane = membrane;
218
244
  this.inferencePolicy = inferencePolicy;
219
245
  this.errorPolicy = errorPolicy;
220
246
  this.syncIntervalMs = syncIntervalMs;
247
+ this.maintenanceIntervalMs = maintenanceIntervalMs;
221
248
  this.processLoggingPersist = processLoggingPersist;
222
249
  this.processLoggingBroadcast = processLoggingBroadcast;
250
+ this.timeZone = timeZone;
223
251
  this.queue = new ProcessQueueImpl();
224
252
  this.usageTracker = new UsageTracker({
225
253
  emitTrace: (e) => this.emitTrace({ ...e }),
@@ -299,7 +327,7 @@ export class AgentFramework {
299
327
  // Already registered
300
328
  }
301
329
  }
302
- const framework = new AgentFramework(store, ownsStore, config.membrane, config.inferencePolicy ?? new DefaultInferencePolicy(), config.errorPolicy ?? new DefaultErrorPolicy(), config.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS, processLoggingPersist, processLoggingBroadcast);
330
+ const framework = new AgentFramework(store, ownsStore, config.membrane, config.inferencePolicy ?? new DefaultInferencePolicy(), config.errorPolicy ?? new DefaultErrorPolicy(), config.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS, config.maintenanceIntervalMs ?? DEFAULT_MAINTENANCE_INTERVAL_MS, processLoggingPersist, processLoggingBroadcast, resolveTimeZone(config.timeZone));
303
331
  // Restore persisted usage data (if any) from prior session
304
332
  framework.restoreUsageState();
305
333
  // Create agents
@@ -433,6 +461,15 @@ export class AgentFramework {
433
461
  }
434
462
  }, this.syncIntervalMs);
435
463
  }
464
+ if (this.maintenanceIntervalMs > 0) {
465
+ this.maintenanceTimer = setInterval(() => {
466
+ this.startQueuedMaintenance();
467
+ }, this.maintenanceIntervalMs);
468
+ this.maintenanceTimer.unref?.();
469
+ // Do not make a restored queue wait a full interval before its first
470
+ // attempt. MCPL/module initialization has completed before start().
471
+ this.startQueuedMaintenance();
472
+ }
436
473
  }
437
474
  /**
438
475
  * Stop the event loop.
@@ -456,6 +493,13 @@ export class AgentFramework {
456
493
  clearInterval(this.syncTimer);
457
494
  this.syncTimer = null;
458
495
  }
496
+ if (this.maintenanceTimer) {
497
+ clearInterval(this.maintenanceTimer);
498
+ this.maintenanceTimer = null;
499
+ }
500
+ if (this.maintenancePass) {
501
+ await this.maintenancePass;
502
+ }
459
503
  if (this.loopPromise) {
460
504
  await this.loopPromise;
461
505
  }
@@ -481,6 +525,100 @@ export class AgentFramework {
481
525
  this.store.close();
482
526
  }
483
527
  }
528
+ /**
529
+ * Advance queued ContextManager work without requiring a new message.
530
+ *
531
+ * Tool definitions are refreshed first because compression replays stored
532
+ * tool cycles and providers reject those requests when the corresponding
533
+ * schemas are absent. Passes never overlap; each agent gets a bounded drain
534
+ * so maintenance cannot monopolize the framework event loop.
535
+ */
536
+ startQueuedMaintenance() {
537
+ if (this.maintenancePass || !this.running)
538
+ return;
539
+ const pass = this.runQueuedMaintenance().catch((error) => {
540
+ console.error('[context-maintenance] pass failed:', error instanceof Error ? error.message : error);
541
+ });
542
+ this.maintenancePass = pass;
543
+ void pass.finally(() => {
544
+ if (this.maintenancePass === pass)
545
+ this.maintenancePass = null;
546
+ });
547
+ }
548
+ async runQueuedMaintenance() {
549
+ const allTools = this.getAllTools();
550
+ const queued = [...this.agents.values()].flatMap((agent) => {
551
+ const cm = agent.getContextManager();
552
+ const tools = allTools.filter((tool) => agent.canUseTool(tool.name));
553
+ cm.setToolDefinitions(tools);
554
+ if (cm.isReady())
555
+ return [];
556
+ const pending = cm.getPendingWork()?.description;
557
+ const progress = this.contextProgress(cm);
558
+ const record = {
559
+ agentName: agent.name,
560
+ startedAt: Date.now(),
561
+ ticks: 0,
562
+ readyBefore: false,
563
+ ...(pending ? { pendingBefore: pending } : {}),
564
+ ...(progress ? { progressBefore: progress } : {}),
565
+ };
566
+ return [{
567
+ agent,
568
+ cm,
569
+ record,
570
+ }];
571
+ });
572
+ if (queued.length === 0)
573
+ return;
574
+ const run = {
575
+ id: ++this.maintenanceRunId,
576
+ startedAt: Date.now(),
577
+ agents: queued.map(item => item.record),
578
+ };
579
+ this.currentMaintenanceRun = run;
580
+ try {
581
+ await Promise.all(queued.map(async ({ agent, cm, record }) => {
582
+ try {
583
+ for (let i = 0; i < MAINTENANCE_TICKS_PER_PASS && this.running && !cm.isReady(); i++) {
584
+ await cm.tick();
585
+ record.ticks++;
586
+ }
587
+ }
588
+ catch (error) {
589
+ record.error = error instanceof Error ? error.message : String(error);
590
+ console.error(`[context-maintenance] agent=${agent.name} failed:`, record.error);
591
+ }
592
+ finally {
593
+ const pending = cm.getPendingWork()?.description;
594
+ record.finishedAt = Date.now();
595
+ record.readyAfter = cm.isReady();
596
+ if (pending)
597
+ record.pendingAfter = pending;
598
+ const progress = this.contextProgress(cm);
599
+ if (progress)
600
+ record.progressAfter = progress;
601
+ }
602
+ }));
603
+ }
604
+ finally {
605
+ run.finishedAt = Date.now();
606
+ run.agents.sort((a, b) => a.agentName.localeCompare(b.agentName));
607
+ this.currentMaintenanceRun = null;
608
+ this.maintenanceHistory.unshift(run);
609
+ if (this.maintenanceHistory.length > 50)
610
+ this.maintenanceHistory.length = 50;
611
+ }
612
+ }
613
+ contextProgress(cm) {
614
+ const strategy = cm.getStrategy();
615
+ if (typeof strategy.getProgressSnapshot !== 'function')
616
+ return undefined;
617
+ const progress = strategy.getProgressSnapshot();
618
+ return progress && typeof progress === 'object'
619
+ ? progress
620
+ : undefined;
621
+ }
484
622
  /**
485
623
  * Push a process event to the queue.
486
624
  */
@@ -592,6 +730,27 @@ export class AgentFramework {
592
730
  }
593
731
  return [...moduleTools, ...this.mcplTools, ...channelTools, ...gateTools];
594
732
  }
733
+ /** Counts-only context-maintenance diagnostics for authenticated debug UIs. */
734
+ getContextMaintenanceSnapshot() {
735
+ const agents = [...this.agents.values()].map((agent) => {
736
+ const cm = agent.getContextManager();
737
+ const pending = cm.getPendingWork()?.description;
738
+ const progress = this.contextProgress(cm);
739
+ return {
740
+ agentName: agent.name,
741
+ ready: cm.isReady(),
742
+ ...(pending ? { pending } : {}),
743
+ ...(progress ? { progress } : {}),
744
+ };
745
+ });
746
+ return structuredClone({
747
+ intervalMs: this.maintenanceIntervalMs,
748
+ ticksPerPass: MAINTENANCE_TICKS_PER_PASS,
749
+ current: this.currentMaintenanceRun,
750
+ history: this.maintenanceHistory,
751
+ agents,
752
+ });
753
+ }
595
754
  /**
596
755
  * Build the membrane-normalized request that WOULD be emitted if `agentName`
597
756
  * were activated right now — WITHOUT running inference, opening a stream, or
@@ -2970,6 +3129,7 @@ export class AgentFramework {
2970
3129
  const category = stopDetails?.category ?? 'unknown';
2971
3130
  console.error(`[inference-refusal] agent=${agent.name} category=${category}` +
2972
3131
  (stopDetails?.explanation ? ` explanation=${stopDetails.explanation}` : ''));
3132
+ this.noteRefusal(agent.name, category, tokenUsage);
2973
3133
  // Rewind: excise the turn that fed the refusal, drop a
2974
3134
  // metadata-only marker, and retry — keeping the agent on its own
2975
3135
  // model instead of substituting a fallback. Driven either by an
@@ -3037,6 +3197,7 @@ export class AgentFramework {
3037
3197
  this.refusalRewinds.set(agent.name, 0);
3038
3198
  }
3039
3199
  this.rewindEpisode.delete(agent.name);
3200
+ this.refusalStreak.delete(agent.name);
3040
3201
  }
3041
3202
  // Dispatch speech (and thoughts if any)
3042
3203
  if (speechContent.length > 0 || thoughts.length > 0) {
@@ -3740,9 +3901,84 @@ export class AgentFramework {
3740
3901
  status: agent.state.status,
3741
3902
  consecutiveInferenceFailures: this.consecutiveInferenceFailures.get(name) ?? 0,
3742
3903
  lastInference: this.lastInferenceAt.get(name) ?? null,
3904
+ refusalStats: this.refusalStats.get(name) ?? null,
3743
3905
  })),
3744
3906
  };
3745
3907
  }
3908
+ /** Append a structured JSONL record to logs/failures.log (best-effort).
3909
+ * Durable and independent of journald/unit log redirects — this is what
3910
+ * connectome-doctor and fleet tooling read. Always stamps `at`. Legacy
3911
+ * records carry {at, agent, consecutive, reason}; new records add `kind`
3912
+ * and kind-specific fields (additive only — doctor parses by regex). */
3913
+ logFailure(record) {
3914
+ try {
3915
+ mkdirSync('logs', { recursive: true });
3916
+ appendFileSync('logs/failures.log', JSON.stringify({ at: new Date().toISOString(), ...record }) + '\n');
3917
+ }
3918
+ catch { /* best-effort */ }
3919
+ }
3920
+ /**
3921
+ * Ops alert — the one escalation path for "a human should hear about this":
3922
+ * (1) durable failures.log record (unless the caller already wrote one),
3923
+ * (2) an `ops:alert` trace so authorized observers get it on the wire,
3924
+ * (3) if CONNECTOME_OPS_WEBHOOK is set, a Discord post throttled to one per
3925
+ * (agent, kind) per cooldown window — a persistent failure re-posts
3926
+ * every ~15 min instead of flooding the channel on every occurrence.
3927
+ * See connectome docs/observability.md.
3928
+ */
3929
+ opsAlert(kind, agentName, message, opts) {
3930
+ if (!opts?.skipLog) {
3931
+ this.logFailure({ agent: agentName, kind, reason: message, ...opts?.data });
3932
+ }
3933
+ this.emitTrace({ type: 'ops:alert', kind, agentName, message, data: opts?.data });
3934
+ const hook = process.env.CONNECTOME_OPS_WEBHOOK;
3935
+ if (!hook)
3936
+ return;
3937
+ const key = `${agentName}:${kind}`;
3938
+ const now = Date.now();
3939
+ if (now - (this.opsAlertLastSent.get(key) ?? 0) < this.opsAlertCooldownMs)
3940
+ return;
3941
+ // Stamp BEFORE the async post so a burst can't race past the cooldown.
3942
+ this.opsAlertLastSent.set(key, now);
3943
+ fetch(hook, {
3944
+ method: 'POST',
3945
+ headers: { 'content-type': 'application/json' },
3946
+ body: JSON.stringify({
3947
+ content: `\u{1F6A8} **${agentName}** ${kind}: ${message.slice(0, 500)}`,
3948
+ }),
3949
+ }).catch((err) => console.error(`[ops-alert] webhook post failed (${key}):`, err instanceof Error ? err.message : err));
3950
+ }
3951
+ /**
3952
+ * Record a model refusal for observability: per-agent stats + a
3953
+ * consecutive-refusal streak (exposed via healthSnapshot), a durable
3954
+ * failures.log record for EVERY refusal (previously invisible outside
3955
+ * stderr), and a throttled ops alert once refusals repeat — one refusal is
3956
+ * often survivable (auto-rewind may clear it); two in a row means stuck.
3957
+ * The streak is reset by any non-refusal completion in the stream driver.
3958
+ */
3959
+ noteRefusal(agentName, category, tokens) {
3960
+ const stats = this.refusalStats.get(agentName)
3961
+ ?? { total: 0, byCategory: {}, lastAt: 0, lastCategory: '' };
3962
+ stats.total += 1;
3963
+ stats.byCategory[category] = (stats.byCategory[category] ?? 0) + 1;
3964
+ stats.lastAt = Date.now();
3965
+ stats.lastCategory = category;
3966
+ this.refusalStats.set(agentName, stats);
3967
+ const streak = (this.refusalStreak.get(agentName) ?? 0) + 1;
3968
+ this.refusalStreak.set(agentName, streak);
3969
+ this.logFailure({
3970
+ agent: agentName,
3971
+ kind: 'refusal',
3972
+ category,
3973
+ streak,
3974
+ locus: this.channelRegistry?.buildChannelContext()?.incoming?.channelId ?? null,
3975
+ tokens: tokens ?? null,
3976
+ });
3977
+ if (streak >= 2) {
3978
+ this.opsAlert('refusal', agentName, `refusal streak ${streak}, category=${category}`, { skipLog: true, data: { category, streak } });
3979
+ }
3980
+ return streak;
3981
+ }
3746
3982
  noteInferenceExhausted(agentName, reason, retryable, errorType) {
3747
3983
  const streak = (this.consecutiveInferenceFailures.get(agentName) ?? 0) + 1;
3748
3984
  this.consecutiveInferenceFailures.set(agentName, streak);
@@ -3751,12 +3987,8 @@ export class AgentFramework {
3751
3987
  console.error(`[inference-failed] agent=${agentName} consecutive=${streak}: ${reason}`);
3752
3988
  // (1b) Machine-greppable durable record, independent of journald/unit log
3753
3989
  // redirects: logs/failures.log under the host's working directory. This is
3754
- // what connectome-doctor reads.
3755
- try {
3756
- mkdirSync('logs', { recursive: true });
3757
- appendFileSync('logs/failures.log', JSON.stringify({ at: new Date().toISOString(), agent: agentName, consecutive: streak, reason }) + '\n');
3758
- }
3759
- catch { /* best-effort */ }
3990
+ // what connectome-doctor reads. Legacy fields kept; `kind` is additive.
3991
+ this.logFailure({ agent: agentName, consecutive: streak, reason, kind: 'inference-exhausted' });
3760
3992
  // (2) Agent-facing chronicle marker (no inference triggered → no loop).
3761
3993
  const agent = this.agents.get(agentName);
3762
3994
  if (agent && process.env.SUPPRESS_INFERENCE_FAILED_MARKER !== '1') {
@@ -3778,17 +4010,10 @@ export class AgentFramework {
3778
4010
  if (streak >= this.inferenceFailureEscalationThreshold) {
3779
4011
  console.error(`[inference-hard-down] agent=${agentName} has FAILED ${streak} consecutive ` +
3780
4012
  `inferences — it cannot complete a turn. Last reason: ${reason}`);
3781
- // Optional ops alert: set CONNECTOME_OPS_WEBHOOK to a Discord webhook URL.
3782
- const hook = process.env.CONNECTOME_OPS_WEBHOOK;
3783
- if (hook) {
3784
- fetch(hook, {
3785
- method: 'POST',
3786
- headers: { 'content-type': 'application/json' },
3787
- body: JSON.stringify({
3788
- content: `\u{1F6A8} **${agentName}** hard-down: ${streak} consecutive inference failures.\nLast reason: ${reason.slice(0, 500)}`,
3789
- }),
3790
- }).catch((err) => console.error('[inference-hard-down] webhook post failed:', err instanceof Error ? err.message : err));
3791
- }
4013
+ // Ops alert (webhook + ops:alert trace), throttled per (agent, kind) —
4014
+ // a hard-down agent re-posts every ~15 min, not on every failed retry.
4015
+ // failures.log already got the per-exhaustion record above.
4016
+ this.opsAlert('hard-down', agentName, `${streak} consecutive inference failures. Last reason: ${reason}`, { skipLog: true });
3792
4017
  // (4) Poison-history breaker: ONLY for `invalid_request` — a 400-class
3793
4018
  // rejection of the history itself. Deliberately NOT keyed on
3794
4019
  // `retryable === false`, which membrane also returns for auth (expired
@@ -4233,6 +4458,13 @@ export class AgentFramework {
4233
4458
  attempt: params.attempt,
4234
4459
  willRetry: connection.willReconnect,
4235
4460
  });
4461
+ // The reconnect loop never gives up (backoff caps at ~300s), so
4462
+ // "the server is effectively down" is an attempt-count judgment:
4463
+ // 5 failed attempts ≈ a few minutes of outage. Throttled per
4464
+ // (serverId, kind), so a long outage re-posts every ~15 min.
4465
+ if (params.attempt >= 5) {
4466
+ this.opsAlert('mcpl-down', connection.id, `MCPL server unreachable (attempt ${params.attempt}): ${params.error}`);
4467
+ }
4236
4468
  });
4237
4469
  // A late response to a tools/call that already timed out, carrying
4238
4470
  // stateful data the server advanced to. The host can't re-inject it (the
@@ -4657,7 +4889,16 @@ export class AgentFramework {
4657
4889
  }
4658
4890
  console.error(`[sleep] agent=${agentName} seconds=${seconds} announce=${announce} until=${new Date(until).toISOString()}`);
4659
4891
  // endTurn: the agent stops here and goes idle for the duration.
4660
- finish({ success: true, data: { sleepingFor: human, until }, endTurn: true });
4892
+ finish({
4893
+ success: true,
4894
+ data: {
4895
+ sleepingFor: human,
4896
+ until,
4897
+ untilLocal: formatZonedDateTime(until, this.timeZone),
4898
+ timeZone: this.timeZone,
4899
+ },
4900
+ endTurn: true,
4901
+ });
4661
4902
  }
4662
4903
  /** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
4663
4904
  * server's declared tag ontology + gate.js status. */