@animalabs/agent-framework 0.16.0 → 0.18.0

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.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,7 +47,7 @@
47
47
  "license": "MIT",
48
48
  "dependencies": {
49
49
  "@animalabs/chronicle": "^0.4.0",
50
- "@animalabs/context-manager": "^0.10.0",
50
+ "@animalabs/context-manager": "^0.11.0",
51
51
  "@animalabs/membrane": "^0.5.78",
52
52
  "chokidar": "^4.0.3",
53
53
  "discord.js": "^14.25.1",
package/src/framework.ts CHANGED
@@ -21,6 +21,7 @@ import type {
21
21
  FrameworkConfig,
22
22
  InferencePolicy,
23
23
  ErrorPolicy,
24
+ ProviderHoldHook,
24
25
  ErrorAction,
25
26
  FrameworkState,
26
27
  TraceEvent,
@@ -97,6 +98,7 @@ import {
97
98
  } from './code-execution/tool-definition.js';
98
99
  import { splitProseSegments } from './prose-segments.js';
99
100
  import { cumulativeDelta } from './usage-accounting.js';
101
+ import { stampThinkingTokenEstimates } from './thinking-token-stamp.js';
100
102
 
101
103
  /** Detect a supported image media type from magic bytes (the model API
102
104
  * rejects mislabeled media types, so trust bytes over extensions).
@@ -744,6 +746,12 @@ interface ProviderAccelerationCooldown {
744
746
  heldRequests: InferenceRequest[];
745
747
  reason: string;
746
748
  failures: number;
749
+ /** Set on a host-requested hold (config.providerHold): the error to
750
+ * re-present to the hook when this slice expires. */
751
+ hostHoldError?: Error;
752
+ /** Armed by an auxiliary (compression) failure with no primary involved:
753
+ * release reopens auxiliary admission without synthesising an inference. */
754
+ auxiliaryOrigin?: boolean;
747
755
  }
748
756
  interface ProviderAccelerationRecovery {
749
757
  startedAt: number;
@@ -948,6 +956,7 @@ export class AgentFramework {
948
956
  private providerAccelerationLastRecovery: Map<string, ProviderAccelerationReceipt> = new Map();
949
957
  private providerAccelerationDefaultCooldownMs = PROVIDER_ACCELERATION_DEFAULT_COOLDOWN_MS;
950
958
  private providerAccelerationJitterMs = PROVIDER_ACCELERATION_JITTER_MS;
959
+ private providerHoldHook: ProviderHoldHook | undefined;
951
960
  private providerAdmissionClosed = false;
952
961
  /** Last time we reported stale (busy-requeued) inference requests, per agent. */
953
962
  private staleWarnAt = new Map<string, number>();
@@ -1452,6 +1461,7 @@ export class AgentFramework {
1452
1461
  normalizeDiscordAwarenessDeadline(config.discordAwarenessDeadlineMs),
1453
1462
  new OperatorLog(operatorLogPath),
1454
1463
  );
1464
+ framework.providerHoldHook = config.providerHold;
1455
1465
 
1456
1466
  // If an offline recovery process crashed after switching Chronicle but
1457
1467
  // before committing its prepared marker batch, the active branch is the
@@ -1915,6 +1925,14 @@ export class AgentFramework {
1915
1925
  }
1916
1926
  gate.auxiliaryInFlight++;
1917
1927
  try { return await run(); }
1928
+ catch (error) {
1929
+ // Compression can be the first call to find a spent quota (idle agent,
1930
+ // scheduled maintenance). Arm the host hold so later passes wait at the
1931
+ // gate instead of retrying into it; this pass still fails to its caller.
1932
+ const agent = this.agents.get(agentName);
1933
+ if (agent && error instanceof Error) this.holdProviderAcceleration(agent, error, undefined, true);
1934
+ throw error;
1935
+ }
1918
1936
  finally {
1919
1937
  gate.auxiliaryInFlight = Math.max(0, gate.auxiliaryInFlight - 1);
1920
1938
  if (gate.auxiliaryInFlight === 0) for (const resolve of gate.idleWaiters.splice(0)) resolve();
@@ -1941,27 +1959,75 @@ export class AgentFramework {
1941
1959
  return a.agentName === b.agentName && a.reason === b.reason && a.source === b.source &&
1942
1960
  a.timestamp === b.timestamp && a.channelId === b.channelId;
1943
1961
  }
1944
- private holdProviderAcceleration(agent: Agent, error: Error, trigger?: InferenceRequest): boolean {
1945
- if (this.ephemeralRuns.has(agent.name) || this.conversationAgentHomes.has(agent.name) || !isOrganizationAccelerationRateLimit(error)) return false;
1946
- const now = Date.now(); const delayMs = this.accelerationCooldownMs(agent.name, error);
1962
+ private consultProviderHold(error: Error, agentName: string): { holdMs: number; reason: string } | undefined {
1963
+ if (!this.providerHoldHook) return undefined;
1964
+ try {
1965
+ const hold = this.providerHoldHook(error, agentName, { model: this.agents.get(agentName)?.model });
1966
+ if (!hold || !Number.isFinite(hold.holdMs) || hold.holdMs <= 0) return undefined;
1967
+ return { holdMs: Math.min(PROVIDER_ACCELERATION_MAX_COOLDOWN_MS, Math.max(1_000, hold.holdMs)),
1968
+ reason: typeof hold.reason === 'string' && hold.reason ? hold.reason : error.message };
1969
+ } catch (err) {
1970
+ console.error(`[provider-cooldown] providerHold hook threw for ${agentName}; treating as no hold:`, err);
1971
+ return undefined;
1972
+ }
1973
+ }
1974
+ /** A host hold whose slice expired: ask the host again before spending an
1975
+ * inference on it. True = extended in place (held requests stay held). */
1976
+ private extendHostProviderHold(agentName: string, cooldown: ProviderAccelerationCooldown): boolean {
1977
+ if (!cooldown.hostHoldError) return false;
1978
+ const hold = this.consultProviderHold(cooldown.hostHoldError, agentName);
1979
+ if (!hold) return false;
1980
+ clearTimeout(cooldown.timer);
1981
+ cooldown.until = Date.now() + hold.holdMs;
1982
+ cooldown.timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agentName), hold.holdMs);
1983
+ cooldown.timer.unref?.();
1984
+ if (hold.reason !== cooldown.reason) {
1985
+ cooldown.reason = hold.reason;
1986
+ console.error(`[provider-cooldown] agent=${agentName} host hold extended — ${hold.reason}`);
1987
+ }
1988
+ return true;
1989
+ }
1990
+ private holdProviderAcceleration(agent: Agent, error: Error, trigger?: InferenceRequest, auxiliary = false): boolean {
1991
+ if (this.ephemeralRuns.has(agent.name) || this.conversationAgentHomes.has(agent.name)) return false;
1992
+ // The host knows more than the message wording does: ask it first. An
1993
+ // auxiliary failure arms host holds only — the built-in acceleration
1994
+ // cooldown stays a primary-path mechanism.
1995
+ const hostHold = this.consultProviderHold(error, agent.name);
1996
+ const acceleration = !hostHold && !auxiliary && isOrganizationAccelerationRateLimit(error);
1997
+ if (!acceleration && !hostHold) return false;
1998
+ const now = Date.now(); const delayMs = hostHold ? hostHold.holdMs : this.accelerationCooldownMs(agent.name, error as MembraneError);
1947
1999
  const existing = this.providerAccelerationCooldowns.get(agent.name);
1948
2000
  const held = existing?.heldRequests ?? [];
1949
2001
  if (trigger && !held.some((r) => this.sameInferenceRequest(r, trigger))) held.push(trigger);
1950
2002
  if (existing) clearTimeout(existing.timer);
1951
2003
  const timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agent.name), delayMs); timer.unref?.();
1952
2004
  this.providerAccelerationCooldowns.set(agent.name, { startedAt: existing?.startedAt ?? now,
1953
- until: now + delayMs, timer, heldRequests: held, reason: error.message,
1954
- failures: (existing?.failures ?? 0) + 1 });
2005
+ until: now + delayMs, timer, heldRequests: held, reason: hostHold?.reason ?? error.message,
2006
+ failures: (existing?.failures ?? 0) + 1, ...(hostHold ? { hostHoldError: error } : {}),
2007
+ ...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}) });
2008
+ if (auxiliary && !existing) {
2009
+ console.error(`[provider-cooldown] agent=${agent.name} host hold (${hostHold!.reason}) from an auxiliary call — ` +
2010
+ `holding primary/auxiliary for ${delayMs}ms`);
2011
+ return true;
2012
+ }
1955
2013
  const gate = this.providerGate(agent.name); gate.primaryPending = true;
1956
2014
  this.providerAccelerationRecoveries.set(agent.name, { startedAt: existing?.startedAt ?? now,
1957
2015
  failures: (existing?.failures ?? 0) + 1, heldRequests: held.length, reason: error.message });
1958
- console.error(`[provider-cooldown] agent=${agent.name} organization acceleration 429 — ` +
2016
+ console.error(`[provider-cooldown] agent=${agent.name} ` +
2017
+ `${hostHold ? `host hold (${hostHold.reason})` : 'organization acceleration 429'} — ` +
1959
2018
  `holding primary/auxiliary for ${delayMs}ms; ${held.length} request(s) retained`);
1960
2019
  return true;
1961
2020
  }
1962
2021
  private releaseProviderAccelerationCooldown(agentName: string): void {
1963
2022
  const cooldown = this.providerAccelerationCooldowns.get(agentName); if (!cooldown) return;
2023
+ if (this.extendHostProviderHold(agentName, cooldown)) return;
1964
2024
  clearTimeout(cooldown.timer); this.providerAccelerationCooldowns.delete(agentName);
2025
+ if (cooldown.auxiliaryOrigin && cooldown.heldRequests.length === 0) {
2026
+ // Nobody asked for an inference while parked: just reopen the gate.
2027
+ console.error(`[provider-cooldown] agent=${agentName} auxiliary hold released after ${Date.now() - cooldown.startedAt}ms`);
2028
+ this.flushAuxiliaryAdmission(agentName);
2029
+ return;
2030
+ }
1965
2031
  const recovery = this.providerAccelerationRecoveries.get(agentName);
1966
2032
  if (recovery) { recovery.releasedAt = Date.now(); recovery.heldRequests = cooldown.heldRequests.length; }
1967
2033
  const requests = cooldown.heldRequests.length > 0 ? cooldown.heldRequests : [{ agentName,
@@ -7322,7 +7388,7 @@ export class AgentFramework {
7322
7388
 
7323
7389
  const providerCooldown = this.providerAccelerationCooldowns?.get(agentName);
7324
7390
  if (providerCooldown) {
7325
- if (now < providerCooldown.until) {
7391
+ if (now < providerCooldown.until || this.extendHostProviderHold(agentName, providerCooldown)) {
7326
7392
  for (const req of requests) {
7327
7393
  if (!providerCooldown.heldRequests.some((r) => this.sameInferenceRequest(r, req))) {
7328
7394
  providerCooldown.heldRequests.push(req);
@@ -8413,6 +8479,12 @@ export class AgentFramework {
8413
8479
  cacheCreationTokens: 0,
8414
8480
  cacheReadTokens: 0,
8415
8481
  };
8482
+ // Output tokens billed since this turn's assistant blocks were last
8483
+ // persisted. The residual over the round's visible blocks is the price of
8484
+ // its hidden thinking, stamped on the signed blocks at persist time
8485
+ // (thinking-token-stamp.ts). Accumulates across max_tokens continuations
8486
+ // within a round; reset at each stamp.
8487
+ let outputTokensSinceStamp = 0;
8416
8488
  // This turn's alive-marker, set at startAgentStream entry. Safe to read
8417
8489
  // from the map here: the turn-alive busy check in processInferenceRequests
8418
8490
  // means no successor turn can have replaced it while we compiled.
@@ -8676,6 +8748,8 @@ export class AgentFramework {
8676
8748
  });
8677
8749
  }
8678
8750
  }
8751
+ assistantBlocks = stampThinkingTokenEstimates(assistantBlocks, outputTokensSinceStamp);
8752
+ outputTokensSinceStamp = 0;
8679
8753
  this.pendingAssistantBlocks.set(agent.name, assistantBlocks);
8680
8754
 
8681
8755
  // Note: max_tokens truncation cannot produce tool-calls events here.
@@ -8824,9 +8898,11 @@ export class AgentFramework {
8824
8898
  b.type === 'tool_use' || b.type === 'tool_result' ? i : last,
8825
8899
  -1
8826
8900
  );
8827
- const terminalContent = lastToolIdx >= 0
8828
- ? response.content.slice(lastToolIdx + 1)
8829
- : response.content;
8901
+ const terminalContent = stampThinkingTokenEstimates(
8902
+ lastToolIdx >= 0 ? response.content.slice(lastToolIdx + 1) : response.content,
8903
+ outputTokensSinceStamp,
8904
+ );
8905
+ outputTokensSinceStamp = 0;
8830
8906
  // This is a whole-response boundary, not a trailing-prose
8831
8907
  // classifier. If the turn executed any genuine structured tool
8832
8908
  // call, preserve its later prose exactly as ordinary history.
@@ -9489,6 +9565,7 @@ export class AgentFramework {
9489
9565
  ),
9490
9566
  };
9491
9567
  previousUsage = cumulativeUsage;
9568
+ outputTokensSinceStamp += perCallUsage.outputTokens;
9492
9569
  const strat = (agent as unknown as {
9493
9570
  getContextManager?: () => { getStrategy?: () => unknown };
9494
9571
  }).getContextManager?.()?.getStrategy?.() as
@@ -11349,6 +11426,8 @@ export class AgentFramework {
11349
11426
  auxiliaryInFlight: gate?.auxiliaryInFlight ?? 0,
11350
11427
  auxiliaryDeferred: gate?.deferredAuxiliary ?? 0,
11351
11428
  cooldownUntil: cooldown?.until ?? null,
11429
+ cooldownReason: cooldown?.reason ?? null,
11430
+ hostHold: cooldown?.hostHoldError !== undefined,
11352
11431
  heldRequests: cooldown?.heldRequests.length ?? 0,
11353
11432
  lastRecovery: this.providerAccelerationLastRecovery?.get(name) ?? null,
11354
11433
  };
@@ -0,0 +1,95 @@
1
+ import type { ContentBlock } from '@animalabs/membrane';
2
+
3
+ /**
4
+ * Stamp signed thinking blocks with the tokens they will cost when replayed.
5
+ *
6
+ * On keep-all models (Opus >= 4.5, Sonnet >= 4.6, Fable/Mythos) every prior
7
+ * assistant thinking block is sent back and billed as INPUT at the size of
8
+ * the full hidden chain of thought — whatever its visible text says (empty
9
+ * under `display: "omitted"`, a short summary under `"summarized"`). The
10
+ * signature encodes that chain but its length is not a reliable measure
11
+ * (2.8–8.7 chars/token measured on Opus 4.8), and the stored block carries
12
+ * no other trace of the size. The response DOES: `usage.output_tokens` is
13
+ * the visible blocks plus the hidden thinking. So at creation, price each
14
+ * carrier block from the residual of this call's output tokens over its
15
+ * visible blocks, split across carriers by signature length, and stamp it
16
+ * as `tokenEstimate` — the field context-manager already prefers over any
17
+ * heuristic for thinking, redacted_thinking and image blocks.
18
+ *
19
+ * Carriers: `thinking` with a non-empty `signature`, `redacted_thinking`
20
+ * with `data`. Blocks that already carry a `tokenEstimate` keep it. When the
21
+ * residual is not positive (no thinking, or the visible estimate over-
22
+ * shoots) nothing is stamped and context-manager's own fallback applies.
23
+ *
24
+ * The visible-block rates mirror context-manager's estimator (prose 2.9,
25
+ * JSON 2.3 chars/token); their precision matters little — on agentic turns
26
+ * the hidden thinking is the bulk of the output, and this stamp is what makes
27
+ * the compiled budget honest (see context-manager `SIGNATURE_CHARS_PER_TOKEN`).
28
+ *
29
+ * Wire safety: membrane's Anthropic formatter builds thinking blocks field
30
+ * by field (`type`, `thinking`, `signature`), so the stamp never reaches the
31
+ * provider; the signature itself is copied, never edited.
32
+ */
33
+ const PROSE_CHARS_PER_TOKEN = 2.9;
34
+ const DENSE_CHARS_PER_TOKEN = 2.3;
35
+
36
+ function carrierWeight(block: ContentBlock): number {
37
+ if (block.type === 'thinking') {
38
+ const sig = (block as { signature?: unknown }).signature;
39
+ return typeof sig === 'string' ? sig.length : 0;
40
+ }
41
+ if (block.type === 'redacted_thinking') {
42
+ const data = (block as { data?: unknown }).data;
43
+ return typeof data === 'string' ? data.length : 0;
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ function visibleTokens(block: ContentBlock): number {
49
+ switch (block.type) {
50
+ case 'text':
51
+ return Math.ceil((block.text ?? '').length / PROSE_CHARS_PER_TOKEN);
52
+ case 'tool_use': {
53
+ let json = '';
54
+ try { json = JSON.stringify(block.input ?? {}); } catch { json = ''; }
55
+ return Math.ceil(json.length / DENSE_CHARS_PER_TOKEN) + 20;
56
+ }
57
+ case 'thinking':
58
+ // Unsigned thinking (text-only providers, XML mode): the text is the
59
+ // whole cost and is priced by context-manager as text.
60
+ return carrierWeight(block) > 0 ? 0 : Math.ceil((block.thinking ?? '').length / PROSE_CHARS_PER_TOKEN);
61
+ default:
62
+ return 0;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Returns a new array; carrier blocks are shallow-copied with `tokenEstimate`
68
+ * set, every other block is the same object.
69
+ */
70
+ export function stampThinkingTokenEstimates(blocks: ContentBlock[], outputTokens: number): ContentBlock[] {
71
+ if (!Array.isArray(blocks) || blocks.length === 0) return blocks;
72
+ if (!Number.isFinite(outputTokens) || outputTokens <= 0) return blocks;
73
+
74
+ let totalWeight = 0;
75
+ let visible = 0;
76
+ for (const block of blocks) {
77
+ const already = (block as { tokenEstimate?: unknown }).tokenEstimate;
78
+ const w = typeof already === 'number' ? 0 : carrierWeight(block);
79
+ if (w > 0) totalWeight += w;
80
+ else visible += visibleTokens(block);
81
+ if (typeof already === 'number') visible += already;
82
+ }
83
+ if (totalWeight <= 0) return blocks;
84
+ const residual = outputTokens - visible;
85
+ if (residual <= 0) return blocks;
86
+
87
+ return blocks.map((block) => {
88
+ const already = (block as { tokenEstimate?: unknown }).tokenEstimate;
89
+ if (typeof already === 'number') return block;
90
+ const w = carrierWeight(block);
91
+ if (w <= 0) return block;
92
+ const tokenEstimate = Math.max(1, Math.round((residual * w) / totalWeight));
93
+ return { ...block, tokenEstimate } as ContentBlock;
94
+ });
95
+ }
@@ -70,6 +70,21 @@ export interface CodeExecutionConfig {
70
70
  maxWakesPerScript?: number;
71
71
  }
72
72
 
73
+ /** See `FrameworkConfig.providerHold`. */
74
+ export interface ProviderHold {
75
+ holdMs: number;
76
+ /** Operator-facing explanation, logged with the hold. */
77
+ reason?: string;
78
+ }
79
+ /** What the framework knows about the failing call. */
80
+ export interface ProviderHoldContext {
81
+ /** Model of the agent whose call failed — a quota window may be model-scoped. */
82
+ model?: string;
83
+ }
84
+ export type ProviderHoldHook = (
85
+ error: Error, agentName: string, context: ProviderHoldContext,
86
+ ) => ProviderHold | undefined;
87
+
73
88
  export interface FrameworkConfig {
74
89
  /**
75
90
  * The subconscious resident (issue #77): a persistent side-agent that
@@ -150,6 +165,27 @@ export interface FrameworkConfig {
150
165
  /** Custom error policy */
151
166
  errorPolicy?: ErrorPolicy;
152
167
 
168
+ /**
169
+ * Host verdict on a failed inference: "this cannot succeed until later".
170
+ * Consulted before the error policy and before the built-in
171
+ * organization-acceleration classification, for a persistent agent's
172
+ * primary inference and for its auxiliary (compression) calls. Ephemeral
173
+ * runs (subagents) and conversation forks are NOT covered: they have no
174
+ * provider-admission gate and keep the ordinary error policy. A hold parks the agent's provider
175
+ * admission (primary and auxiliary) instead of retrying, and — like the
176
+ * built-in organization-acceleration cooldown — records no failed turn and
177
+ * does not feed the hard-down streak. The motivating case is a subscription
178
+ * credential whose quota window is spent: its 429 looks like a throttle,
179
+ * but retrying cannot help until the window resets.
180
+ *
181
+ * Holds are served in slices of at most 10 minutes. When a slice expires
182
+ * the hook is consulted again with the same error, before any inference is
183
+ * attempted; it extends the hold by returning another, or releases it by
184
+ * returning undefined. Must be synchronous and cheap; a throwing hook is
185
+ * treated as "no hold".
186
+ */
187
+ providerHold?: ProviderHoldHook;
188
+
153
189
  /** Interval for periodic store sync in milliseconds (default: 1000ms, 0 to disable) */
154
190
  syncIntervalMs?: number;
155
191