@animalabs/agent-framework 0.6.2 → 0.6.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/agent-framework",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution",
5
5
  "repository": {
6
6
  "type": "git",
package/src/framework.ts CHANGED
@@ -40,6 +40,9 @@ import type {
40
40
  AgentState,
41
41
  Module,
42
42
  SpeechContext,
43
+ ContextMaintenanceRun,
44
+ ContextMaintenanceAgentRun,
45
+ ContextMaintenanceSnapshot,
43
46
  } from './types/index.js';
44
47
  import { ProcessQueueImpl } from './queue.js';
45
48
  import { Agent } from './agent.js';
@@ -144,6 +147,10 @@ class DefaultErrorPolicy implements ErrorPolicy {
144
147
 
145
148
  /** Default sync interval in milliseconds */
146
149
  const DEFAULT_SYNC_INTERVAL_MS = 1000;
150
+ /** Poll pending context-strategy maintenance independently of user activity. */
151
+ const DEFAULT_MAINTENANCE_INTERVAL_MS = 5000;
152
+ /** Bound one pass so a large backlog yields to inference and other agents. */
153
+ const MAINTENANCE_TICKS_PER_PASS = 8;
147
154
 
148
155
  /**
149
156
  * Extract fields the EventGate cares about from a ProcessEvent. The set of
@@ -253,6 +260,12 @@ export class AgentFramework {
253
260
  private traceListeners: TraceEventListener[] = [];
254
261
  private syncIntervalMs: number;
255
262
  private syncTimer: ReturnType<typeof setInterval> | null = null;
263
+ private maintenanceIntervalMs: number;
264
+ private maintenanceTimer: ReturnType<typeof setInterval> | null = null;
265
+ private maintenancePass: Promise<void> | null = null;
266
+ private maintenanceRunId = 0;
267
+ private currentMaintenanceRun: ContextMaintenanceRun | null = null;
268
+ private maintenanceHistory: ContextMaintenanceRun[] = [];
256
269
  /** Last time we console-warned about stale (busy-requeued) inference requests, per agent. */
257
270
  private staleWarnAt = new Map<string, number>();
258
271
  /** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
@@ -267,6 +280,19 @@ export class AgentFramework {
267
280
  /** N consecutive failed inferences ⇒ the agent is treated as hard-down and
268
281
  * escalated loudly to stderr. */
269
282
  private readonly inferenceFailureEscalationThreshold = 3;
283
+ /** Per-(agent,kind) timestamp of the last ops webhook post. Throttles
284
+ * opsAlert() so a persistent failure re-posts once per cooldown window
285
+ * instead of on every occurrence. */
286
+ private opsAlertLastSent: Map<string, number> = new Map();
287
+ /** Cooldown between webhook posts for the same (agent, kind). */
288
+ private readonly opsAlertCooldownMs = 15 * 60_000;
289
+ /** Per-agent refusal bookkeeping for observability — exposed via
290
+ * healthSnapshot() and mirrored to failures.log / ops alerts. */
291
+ private refusalStats: Map<string, { total: number; byCategory: Record<string, number>; lastAt: number; lastCategory: string }> = new Map();
292
+ /** Per-agent count of consecutive refusals (reset on any non-refusal
293
+ * completion). Distinct from refusalRewinds, which budgets the auto-rewind
294
+ * loop — this one drives ops alerting. */
295
+ private refusalStreak: Map<string, number> = new Map();
270
296
  /** Per-agent count of consecutive refusal-driven rewinds in the current turn
271
297
  * chain (reset when a turn completes without a refusal). Bounds the auto
272
298
  * rewind loop — see refusalHandling + rewindTriggeringTurn. */
@@ -356,6 +382,7 @@ export class AgentFramework {
356
382
  inferencePolicy: InferencePolicy,
357
383
  errorPolicy: ErrorPolicy,
358
384
  syncIntervalMs: number,
385
+ maintenanceIntervalMs: number,
359
386
  processLoggingPersist: boolean,
360
387
  processLoggingBroadcast: boolean
361
388
  ) {
@@ -365,6 +392,7 @@ export class AgentFramework {
365
392
  this.inferencePolicy = inferencePolicy;
366
393
  this.errorPolicy = errorPolicy;
367
394
  this.syncIntervalMs = syncIntervalMs;
395
+ this.maintenanceIntervalMs = maintenanceIntervalMs;
368
396
  this.processLoggingPersist = processLoggingPersist;
369
397
  this.processLoggingBroadcast = processLoggingBroadcast;
370
398
  this.queue = new ProcessQueueImpl();
@@ -455,6 +483,7 @@ export class AgentFramework {
455
483
  config.inferencePolicy ?? new DefaultInferencePolicy(),
456
484
  config.errorPolicy ?? new DefaultErrorPolicy(),
457
485
  config.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS,
486
+ config.maintenanceIntervalMs ?? DEFAULT_MAINTENANCE_INTERVAL_MS,
458
487
  processLoggingPersist,
459
488
  processLoggingBroadcast
460
489
  );
@@ -610,6 +639,16 @@ export class AgentFramework {
610
639
  }
611
640
  }, this.syncIntervalMs);
612
641
  }
642
+
643
+ if (this.maintenanceIntervalMs > 0) {
644
+ this.maintenanceTimer = setInterval(() => {
645
+ this.startQueuedMaintenance();
646
+ }, this.maintenanceIntervalMs);
647
+ this.maintenanceTimer.unref?.();
648
+ // Do not make a restored queue wait a full interval before its first
649
+ // attempt. MCPL/module initialization has completed before start().
650
+ this.startQueuedMaintenance();
651
+ }
613
652
  }
614
653
 
615
654
  /**
@@ -638,6 +677,14 @@ export class AgentFramework {
638
677
  this.syncTimer = null;
639
678
  }
640
679
 
680
+ if (this.maintenanceTimer) {
681
+ clearInterval(this.maintenanceTimer);
682
+ this.maintenanceTimer = null;
683
+ }
684
+ if (this.maintenancePass) {
685
+ await this.maintenancePass;
686
+ }
687
+
641
688
  if (this.loopPromise) {
642
689
  await this.loopPromise;
643
690
  }
@@ -668,6 +715,103 @@ export class AgentFramework {
668
715
  }
669
716
  }
670
717
 
718
+ /**
719
+ * Advance queued ContextManager work without requiring a new message.
720
+ *
721
+ * Tool definitions are refreshed first because compression replays stored
722
+ * tool cycles and providers reject those requests when the corresponding
723
+ * schemas are absent. Passes never overlap; each agent gets a bounded drain
724
+ * so maintenance cannot monopolize the framework event loop.
725
+ */
726
+ private startQueuedMaintenance(): void {
727
+ if (this.maintenancePass || !this.running) return;
728
+ const pass = this.runQueuedMaintenance().catch((error) => {
729
+ console.error(
730
+ '[context-maintenance] pass failed:',
731
+ error instanceof Error ? error.message : error,
732
+ );
733
+ });
734
+ this.maintenancePass = pass;
735
+ void pass.finally(() => {
736
+ if (this.maintenancePass === pass) this.maintenancePass = null;
737
+ });
738
+ }
739
+
740
+ private async runQueuedMaintenance(): Promise<void> {
741
+ const allTools = this.getAllTools();
742
+ const queued = [...this.agents.values()].flatMap((agent) => {
743
+ const cm = agent.getContextManager();
744
+ const tools = allTools.filter((tool) => agent.canUseTool(tool.name));
745
+ cm.setToolDefinitions(tools);
746
+ if (cm.isReady()) return [];
747
+ const pending = cm.getPendingWork()?.description;
748
+ const progress = this.contextProgress(cm);
749
+ const record: ContextMaintenanceAgentRun = {
750
+ agentName: agent.name,
751
+ startedAt: Date.now(),
752
+ ticks: 0,
753
+ readyBefore: false,
754
+ ...(pending ? { pendingBefore: pending } : {}),
755
+ ...(progress ? { progressBefore: progress } : {}),
756
+ };
757
+ return [{
758
+ agent,
759
+ cm,
760
+ record,
761
+ }];
762
+ });
763
+ if (queued.length === 0) return;
764
+
765
+ const run: ContextMaintenanceRun = {
766
+ id: ++this.maintenanceRunId,
767
+ startedAt: Date.now(),
768
+ agents: queued.map(item => item.record),
769
+ };
770
+ this.currentMaintenanceRun = run;
771
+
772
+ try {
773
+ await Promise.all(queued.map(async ({ agent, cm, record }) => {
774
+ try {
775
+ for (
776
+ let i = 0;
777
+ i < MAINTENANCE_TICKS_PER_PASS && this.running && !cm.isReady();
778
+ i++
779
+ ) {
780
+ await cm.tick();
781
+ record.ticks++;
782
+ }
783
+ } catch (error) {
784
+ record.error = error instanceof Error ? error.message : String(error);
785
+ console.error(`[context-maintenance] agent=${agent.name} failed:`, record.error);
786
+ } finally {
787
+ const pending = cm.getPendingWork()?.description;
788
+ record.finishedAt = Date.now();
789
+ record.readyAfter = cm.isReady();
790
+ if (pending) record.pendingAfter = pending;
791
+ const progress = this.contextProgress(cm);
792
+ if (progress) record.progressAfter = progress;
793
+ }
794
+ }));
795
+ } finally {
796
+ run.finishedAt = Date.now();
797
+ run.agents.sort((a, b) => a.agentName.localeCompare(b.agentName));
798
+ this.currentMaintenanceRun = null;
799
+ this.maintenanceHistory.unshift(run);
800
+ if (this.maintenanceHistory.length > 50) this.maintenanceHistory.length = 50;
801
+ }
802
+ }
803
+
804
+ private contextProgress(cm: ContextManager): Record<string, unknown> | undefined {
805
+ const strategy = cm.getStrategy() as {
806
+ getProgressSnapshot?: () => unknown;
807
+ };
808
+ if (typeof strategy.getProgressSnapshot !== 'function') return undefined;
809
+ const progress = strategy.getProgressSnapshot();
810
+ return progress && typeof progress === 'object'
811
+ ? progress as Record<string, unknown>
812
+ : undefined;
813
+ }
814
+
671
815
  /**
672
816
  * Push a process event to the queue.
673
817
  */
@@ -790,6 +934,28 @@ export class AgentFramework {
790
934
  return [...moduleTools, ...this.mcplTools, ...channelTools, ...gateTools];
791
935
  }
792
936
 
937
+ /** Counts-only context-maintenance diagnostics for authenticated debug UIs. */
938
+ getContextMaintenanceSnapshot(): ContextMaintenanceSnapshot {
939
+ const agents = [...this.agents.values()].map((agent) => {
940
+ const cm = agent.getContextManager();
941
+ const pending = cm.getPendingWork()?.description;
942
+ const progress = this.contextProgress(cm);
943
+ return {
944
+ agentName: agent.name,
945
+ ready: cm.isReady(),
946
+ ...(pending ? { pending } : {}),
947
+ ...(progress ? { progress } : {}),
948
+ };
949
+ });
950
+ return structuredClone({
951
+ intervalMs: this.maintenanceIntervalMs,
952
+ ticksPerPass: MAINTENANCE_TICKS_PER_PASS,
953
+ current: this.currentMaintenanceRun,
954
+ history: this.maintenanceHistory,
955
+ agents,
956
+ });
957
+ }
958
+
793
959
  /**
794
960
  * Build the membrane-normalized request that WOULD be emitted if `agentName`
795
961
  * were activated right now — WITHOUT running inference, opening a stream, or
@@ -3544,6 +3710,8 @@ export class AgentFramework {
3544
3710
  (stopDetails?.explanation ? ` explanation=${stopDetails.explanation}` : ''),
3545
3711
  );
3546
3712
 
3713
+ this.noteRefusal(agent.name, category, tokenUsage);
3714
+
3547
3715
  // Rewind: excise the turn that fed the refusal, drop a
3548
3716
  // metadata-only marker, and retry — keeping the agent on its own
3549
3717
  // model instead of substituting a fallback. Driven either by an
@@ -3621,6 +3789,7 @@ export class AgentFramework {
3621
3789
  this.refusalRewinds.set(agent.name, 0);
3622
3790
  }
3623
3791
  this.rewindEpisode.delete(agent.name);
3792
+ this.refusalStreak.delete(agent.name);
3624
3793
  }
3625
3794
 
3626
3795
  // Dispatch speech (and thoughts if any)
@@ -4390,10 +4559,108 @@ export class AgentFramework {
4390
4559
  status: agent.state.status,
4391
4560
  consecutiveInferenceFailures: this.consecutiveInferenceFailures.get(name) ?? 0,
4392
4561
  lastInference: this.lastInferenceAt.get(name) ?? null,
4562
+ refusalStats: this.refusalStats.get(name) ?? null,
4393
4563
  })),
4394
4564
  };
4395
4565
  }
4396
4566
 
4567
+ /** Append a structured JSONL record to logs/failures.log (best-effort).
4568
+ * Durable and independent of journald/unit log redirects — this is what
4569
+ * connectome-doctor and fleet tooling read. Always stamps `at`. Legacy
4570
+ * records carry {at, agent, consecutive, reason}; new records add `kind`
4571
+ * and kind-specific fields (additive only — doctor parses by regex). */
4572
+ private logFailure(record: Record<string, unknown>): void {
4573
+ try {
4574
+ mkdirSync('logs', { recursive: true });
4575
+ appendFileSync(
4576
+ 'logs/failures.log',
4577
+ JSON.stringify({ at: new Date().toISOString(), ...record }) + '\n',
4578
+ );
4579
+ } catch { /* best-effort */ }
4580
+ }
4581
+
4582
+ /**
4583
+ * Ops alert — the one escalation path for "a human should hear about this":
4584
+ * (1) durable failures.log record (unless the caller already wrote one),
4585
+ * (2) an `ops:alert` trace so authorized observers get it on the wire,
4586
+ * (3) if CONNECTOME_OPS_WEBHOOK is set, a Discord post throttled to one per
4587
+ * (agent, kind) per cooldown window — a persistent failure re-posts
4588
+ * every ~15 min instead of flooding the channel on every occurrence.
4589
+ * See connectome docs/observability.md.
4590
+ */
4591
+ private opsAlert(
4592
+ kind: string,
4593
+ agentName: string,
4594
+ message: string,
4595
+ opts?: { data?: Record<string, unknown>; skipLog?: boolean },
4596
+ ): void {
4597
+ if (!opts?.skipLog) {
4598
+ this.logFailure({ agent: agentName, kind, reason: message, ...opts?.data });
4599
+ }
4600
+ this.emitTrace({ type: 'ops:alert', kind, agentName, message, data: opts?.data });
4601
+
4602
+ const hook = process.env.CONNECTOME_OPS_WEBHOOK;
4603
+ if (!hook) return;
4604
+ const key = `${agentName}:${kind}`;
4605
+ const now = Date.now();
4606
+ if (now - (this.opsAlertLastSent.get(key) ?? 0) < this.opsAlertCooldownMs) return;
4607
+ // Stamp BEFORE the async post so a burst can't race past the cooldown.
4608
+ this.opsAlertLastSent.set(key, now);
4609
+ fetch(hook, {
4610
+ method: 'POST',
4611
+ headers: { 'content-type': 'application/json' },
4612
+ body: JSON.stringify({
4613
+ content: `\u{1F6A8} **${agentName}** ${kind}: ${message.slice(0, 500)}`,
4614
+ }),
4615
+ }).catch((err) => console.error(
4616
+ `[ops-alert] webhook post failed (${key}):`,
4617
+ err instanceof Error ? err.message : err,
4618
+ ));
4619
+ }
4620
+
4621
+ /**
4622
+ * Record a model refusal for observability: per-agent stats + a
4623
+ * consecutive-refusal streak (exposed via healthSnapshot), a durable
4624
+ * failures.log record for EVERY refusal (previously invisible outside
4625
+ * stderr), and a throttled ops alert once refusals repeat — one refusal is
4626
+ * often survivable (auto-rewind may clear it); two in a row means stuck.
4627
+ * The streak is reset by any non-refusal completion in the stream driver.
4628
+ */
4629
+ private noteRefusal(
4630
+ agentName: string,
4631
+ category: string,
4632
+ tokens?: { input: number; output: number },
4633
+ ): number {
4634
+ const stats = this.refusalStats.get(agentName)
4635
+ ?? { total: 0, byCategory: {}, lastAt: 0, lastCategory: '' };
4636
+ stats.total += 1;
4637
+ stats.byCategory[category] = (stats.byCategory[category] ?? 0) + 1;
4638
+ stats.lastAt = Date.now();
4639
+ stats.lastCategory = category;
4640
+ this.refusalStats.set(agentName, stats);
4641
+
4642
+ const streak = (this.refusalStreak.get(agentName) ?? 0) + 1;
4643
+ this.refusalStreak.set(agentName, streak);
4644
+
4645
+ this.logFailure({
4646
+ agent: agentName,
4647
+ kind: 'refusal',
4648
+ category,
4649
+ streak,
4650
+ locus: this.channelRegistry?.buildChannelContext()?.incoming?.channelId ?? null,
4651
+ tokens: tokens ?? null,
4652
+ });
4653
+ if (streak >= 2) {
4654
+ this.opsAlert(
4655
+ 'refusal',
4656
+ agentName,
4657
+ `refusal streak ${streak}, category=${category}`,
4658
+ { skipLog: true, data: { category, streak } },
4659
+ );
4660
+ }
4661
+ return streak;
4662
+ }
4663
+
4397
4664
  private noteInferenceExhausted(
4398
4665
  agentName: string,
4399
4666
  reason: string,
@@ -4409,14 +4676,8 @@ export class AgentFramework {
4409
4676
 
4410
4677
  // (1b) Machine-greppable durable record, independent of journald/unit log
4411
4678
  // redirects: logs/failures.log under the host's working directory. This is
4412
- // what connectome-doctor reads.
4413
- try {
4414
- mkdirSync('logs', { recursive: true });
4415
- appendFileSync(
4416
- 'logs/failures.log',
4417
- JSON.stringify({ at: new Date().toISOString(), agent: agentName, consecutive: streak, reason }) + '\n',
4418
- );
4419
- } catch { /* best-effort */ }
4679
+ // what connectome-doctor reads. Legacy fields kept; `kind` is additive.
4680
+ this.logFailure({ agent: agentName, consecutive: streak, reason, kind: 'inference-exhausted' });
4420
4681
 
4421
4682
  // (2) Agent-facing chronicle marker (no inference triggered → no loop).
4422
4683
  const agent = this.agents.get(agentName);
@@ -4447,17 +4708,15 @@ export class AgentFramework {
4447
4708
  `inferences — it cannot complete a turn. Last reason: ${reason}`,
4448
4709
  );
4449
4710
 
4450
- // Optional ops alert: set CONNECTOME_OPS_WEBHOOK to a Discord webhook URL.
4451
- const hook = process.env.CONNECTOME_OPS_WEBHOOK;
4452
- if (hook) {
4453
- fetch(hook, {
4454
- method: 'POST',
4455
- headers: { 'content-type': 'application/json' },
4456
- body: JSON.stringify({
4457
- content: `\u{1F6A8} **${agentName}** hard-down: ${streak} consecutive inference failures.\nLast reason: ${reason.slice(0, 500)}`,
4458
- }),
4459
- }).catch((err) => console.error('[inference-hard-down] webhook post failed:', err instanceof Error ? err.message : err));
4460
- }
4711
+ // Ops alert (webhook + ops:alert trace), throttled per (agent, kind) —
4712
+ // a hard-down agent re-posts every ~15 min, not on every failed retry.
4713
+ // failures.log already got the per-exhaustion record above.
4714
+ this.opsAlert(
4715
+ 'hard-down',
4716
+ agentName,
4717
+ `${streak} consecutive inference failures. Last reason: ${reason}`,
4718
+ { skipLog: true },
4719
+ );
4461
4720
 
4462
4721
  // (4) Poison-history breaker: ONLY for `invalid_request` — a 400-class
4463
4722
  // rejection of the history itself. Deliberately NOT keyed on
@@ -5030,6 +5289,17 @@ export class AgentFramework {
5030
5289
  attempt: params.attempt,
5031
5290
  willRetry: connection.willReconnect,
5032
5291
  });
5292
+ // The reconnect loop never gives up (backoff caps at ~300s), so
5293
+ // "the server is effectively down" is an attempt-count judgment:
5294
+ // 5 failed attempts ≈ a few minutes of outage. Throttled per
5295
+ // (serverId, kind), so a long outage re-posts every ~15 min.
5296
+ if (params.attempt >= 5) {
5297
+ this.opsAlert(
5298
+ 'mcpl-down',
5299
+ connection.id,
5300
+ `MCPL server unreachable (attempt ${params.attempt}): ${params.error}`,
5301
+ );
5302
+ }
5033
5303
  });
5034
5304
 
5035
5305
  // A late response to a tools/call that already timed out, carrying
@@ -56,6 +56,13 @@ export interface FrameworkConfig {
56
56
  /** Interval for periodic store sync in milliseconds (default: 1000ms, 0 to disable) */
57
57
  syncIntervalMs?: number;
58
58
 
59
+ /**
60
+ * Interval for queued context-maintenance polling in milliseconds
61
+ * (default: 5000ms, 0 to disable). Each pass refreshes the agent's live tool
62
+ * definitions, then advances pending compression/indexing work.
63
+ */
64
+ maintenanceIntervalMs?: number;
65
+
59
66
  /** Process logging configuration (default: disabled) */
60
67
  processLogging?: ProcessLoggingConfig;
61
68
 
@@ -92,6 +99,43 @@ export interface FrameworkConfig {
92
99
  conversations?: ConversationRouterConfig;
93
100
  }
94
101
 
102
+ /** One agent's outcome within a periodic context-maintenance pass. */
103
+ export interface ContextMaintenanceAgentRun {
104
+ agentName: string;
105
+ startedAt: number;
106
+ finishedAt?: number;
107
+ ticks: number;
108
+ readyBefore: boolean;
109
+ readyAfter?: boolean;
110
+ pendingBefore?: string;
111
+ pendingAfter?: string;
112
+ progressBefore?: Record<string, unknown>;
113
+ progressAfter?: Record<string, unknown>;
114
+ error?: string;
115
+ }
116
+
117
+ /** A bounded periodic context-maintenance pass. */
118
+ export interface ContextMaintenanceRun {
119
+ id: number;
120
+ startedAt: number;
121
+ finishedAt?: number;
122
+ agents: ContextMaintenanceAgentRun[];
123
+ }
124
+
125
+ /** Debug-safe maintenance state. Contains counts and errors, never messages. */
126
+ export interface ContextMaintenanceSnapshot {
127
+ intervalMs: number;
128
+ ticksPerPass: number;
129
+ current: ContextMaintenanceRun | null;
130
+ history: ContextMaintenanceRun[];
131
+ agents: Array<{
132
+ agentName: string;
133
+ ready: boolean;
134
+ pending?: string;
135
+ progress?: Record<string, unknown>;
136
+ }>;
137
+ }
138
+
95
139
  /**
96
140
  * Policy for deciding when to run inference.
97
141
  */
@@ -321,6 +321,22 @@ export type TraceEvent =
321
321
  hadState: boolean;
322
322
  /** True when the dropped response carried a server-managed `checkpoint`. */
323
323
  hadCheckpoint: boolean;
324
+ })
325
+
326
+ // Ops / fleet observability. Everything the framework's opsAlert() escalates
327
+ // (refusals, hard-down, MCPL unreachable, …) mirrored onto the trace bus so
328
+ // authorized observers see alerts on the same wire as the rest of the
329
+ // agent's internal life. Discord (CONNECTOME_OPS_WEBHOOK) is one sink of
330
+ // this stream, not a separate system. See connectome docs/observability.md.
331
+ | (TraceEventBase & {
332
+ type: 'ops:alert';
333
+ /** Alert kind — open set: 'refusal' | 'hard-down' | 'mcpl-down' | … */
334
+ kind: string;
335
+ /** Agent name, or a server/component id for non-agent alerts (e.g. mcpl-down). */
336
+ agentName: string;
337
+ message: string;
338
+ /** Kind-specific structured payload (mirrors the failures.log record). */
339
+ data?: Record<string, unknown>;
324
340
  });
325
341
 
326
342
  /**