@animalabs/agent-framework 0.17.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.17.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
@@ -98,6 +98,7 @@ import {
98
98
  } from './code-execution/tool-definition.js';
99
99
  import { splitProseSegments } from './prose-segments.js';
100
100
  import { cumulativeDelta } from './usage-accounting.js';
101
+ import { stampThinkingTokenEstimates } from './thinking-token-stamp.js';
101
102
 
102
103
  /** Detect a supported image media type from magic bytes (the model API
103
104
  * rejects mislabeled media types, so trust bytes over extensions).
@@ -8478,6 +8479,12 @@ export class AgentFramework {
8478
8479
  cacheCreationTokens: 0,
8479
8480
  cacheReadTokens: 0,
8480
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;
8481
8488
  // This turn's alive-marker, set at startAgentStream entry. Safe to read
8482
8489
  // from the map here: the turn-alive busy check in processInferenceRequests
8483
8490
  // means no successor turn can have replaced it while we compiled.
@@ -8741,6 +8748,8 @@ export class AgentFramework {
8741
8748
  });
8742
8749
  }
8743
8750
  }
8751
+ assistantBlocks = stampThinkingTokenEstimates(assistantBlocks, outputTokensSinceStamp);
8752
+ outputTokensSinceStamp = 0;
8744
8753
  this.pendingAssistantBlocks.set(agent.name, assistantBlocks);
8745
8754
 
8746
8755
  // Note: max_tokens truncation cannot produce tool-calls events here.
@@ -8889,9 +8898,11 @@ export class AgentFramework {
8889
8898
  b.type === 'tool_use' || b.type === 'tool_result' ? i : last,
8890
8899
  -1
8891
8900
  );
8892
- const terminalContent = lastToolIdx >= 0
8893
- ? response.content.slice(lastToolIdx + 1)
8894
- : response.content;
8901
+ const terminalContent = stampThinkingTokenEstimates(
8902
+ lastToolIdx >= 0 ? response.content.slice(lastToolIdx + 1) : response.content,
8903
+ outputTokensSinceStamp,
8904
+ );
8905
+ outputTokensSinceStamp = 0;
8895
8906
  // This is a whole-response boundary, not a trailing-prose
8896
8907
  // classifier. If the turn executed any genuine structured tool
8897
8908
  // call, preserve its later prose exactly as ordinary history.
@@ -9554,6 +9565,7 @@ export class AgentFramework {
9554
9565
  ),
9555
9566
  };
9556
9567
  previousUsage = cumulativeUsage;
9568
+ outputTokensSinceStamp += perCallUsage.outputTokens;
9557
9569
  const strat = (agent as unknown as {
9558
9570
  getContextManager?: () => { getStrategy?: () => unknown };
9559
9571
  }).getContextManager?.()?.getStrategy?.() as
@@ -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
+ }