@animalabs/agent-framework 0.17.0 → 0.19.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.
Files changed (31) hide show
  1. package/dist/src/framework.d.ts +8 -5
  2. package/dist/src/framework.d.ts.map +1 -1
  3. package/dist/src/framework.js +26 -14
  4. package/dist/src/framework.js.map +1 -1
  5. package/dist/src/mcpl/channel-registry.d.ts +15 -0
  6. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  7. package/dist/src/mcpl/channel-registry.js +50 -4
  8. package/dist/src/mcpl/channel-registry.js.map +1 -1
  9. package/dist/src/thinking-token-stamp.d.ts +7 -0
  10. package/dist/src/thinking-token-stamp.d.ts.map +1 -0
  11. package/dist/src/thinking-token-stamp.js +103 -0
  12. package/dist/src/thinking-token-stamp.js.map +1 -0
  13. package/dist/test/channels-changed-respond-first.test.d.ts +2 -0
  14. package/dist/test/channels-changed-respond-first.test.d.ts.map +1 -0
  15. package/dist/test/channels-changed-respond-first.test.js +74 -0
  16. package/dist/test/channels-changed-respond-first.test.js.map +1 -0
  17. package/dist/test/journal-tool.test.d.ts +2 -0
  18. package/dist/test/journal-tool.test.d.ts.map +1 -0
  19. package/dist/test/journal-tool.test.js +33 -0
  20. package/dist/test/journal-tool.test.js.map +1 -0
  21. package/dist/test/overbudget-drain-breaker.test.js +44 -4
  22. package/dist/test/overbudget-drain-breaker.test.js.map +1 -1
  23. package/dist/test/thinking-token-stamp.test.d.ts +12 -0
  24. package/dist/test/thinking-token-stamp.test.d.ts.map +1 -0
  25. package/dist/test/thinking-token-stamp.test.js +129 -0
  26. package/dist/test/thinking-token-stamp.test.js.map +1 -0
  27. package/dist/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +2 -2
  29. package/src/framework.ts +29 -14
  30. package/src/mcpl/channel-registry.ts +55 -3
  31. package/src/thinking-token-stamp.ts +95 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/agent-framework",
3
- "version": "0.17.0",
3
+ "version": "0.19.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
@@ -5,7 +5,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeF
5
5
  import { JsStore } from '@animalabs/chronicle';
6
6
  import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
7
7
  import { MembraneError } from '@animalabs/membrane';
8
- import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy } from '@animalabs/context-manager';
8
+ import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError } from '@animalabs/context-manager';
9
9
  import type { CacheWireReceipt } from './kv-unified-wire.js';
10
10
  import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js';
11
11
  import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
@@ -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
@@ -11018,11 +11030,11 @@ export class AgentFramework {
11018
11030
  return;
11019
11031
  }
11020
11032
 
11021
- // Route synthesized 'think' (private reasoning) and 'skip_reply' (deliberate
11022
- // stay-silent) tools — handled by the channel registry like the other
11033
+ // Route synthesized 'think' (private reasoning), 'journal' (private
11034
+ // long-form notes) and 'skip_reply' (deliberate stay-silent) tools — handled by the channel registry like the other
11023
11035
  // synthesized channel tools, but they aren't `channel_`-prefixed so they
11024
11036
  // need an explicit route here.
11025
- if ((enrichedCall.name === 'think' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
11037
+ if ((enrichedCall.name === 'think' || enrichedCall.name === 'journal' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
11026
11038
  // skip_reply(wake_in_seconds): arm a gate self-wake so "not replying
11027
11039
  // NOW" can also mean "back in a moment" — ends the turn, then wakes
11028
11040
  // the agent after N seconds unless something else wakes it first
@@ -11545,24 +11557,27 @@ export class AgentFramework {
11545
11557
  * quarantine, OverBudget drain kick) gate on `errorType`, so classification
11546
11558
  * drift between sites would silently disable a safety net.
11547
11559
  *
11548
- * context-manager's OverBudgetError is recognized by `err.name`: CM does not
11549
- * export the class from its package root, so a cross-package `instanceof` is
11550
- * unavailable — but `name` is set in its constructor and survives the package
11551
- * boundary. Deliberately NOT a message match: the message wording belongs to
11552
- * CM and can be reworded without warning.
11560
+ * context-manager's refusal errors are matched by a real `instanceof` since
11561
+ * CM exports them from its package root (context-manager#41 / #71; floor
11562
+ * `^0.7.0`, ours is higher). The `err.name` comparison stays as a FALLBACK
11563
+ * only: a deployment that ends up with two CM copies (a peer package
11564
+ * pinning a different version) constructs errors from a different class
11565
+ * object, and `name` — set in the constructor — survives that boundary.
11566
+ * Deliberately NOT a message match: the message wording belongs to CM and
11567
+ * can be reworded without warning.
11553
11568
  */
11554
11569
  private classifyInferenceError(err: Error): { retryable?: boolean; errorType?: string } {
11555
11570
  if (err instanceof MembraneError) {
11556
11571
  return { retryable: err.retryable, errorType: err.type };
11557
11572
  }
11558
- if (err.name === 'OverBudgetError') {
11573
+ if (err instanceof OverBudgetError || err.name === 'OverBudgetError') {
11559
11574
  return { errorType: 'over_budget' };
11560
11575
  }
11561
11576
  // context-manager's fatal coverage invariant (fix/coverage-invariant-fatal,
11562
11577
  // 2026-07-26): a compile REFUSES rather than shipping a context with
11563
11578
  // silently-dropped messages. Recovery is identical to over_budget — kick
11564
11579
  // the compression drain so summaries cover the un-represented span.
11565
- if (err.name === 'UncoveredDropError') {
11580
+ if (err instanceof UncoveredDropError || err.name === 'UncoveredDropError') {
11566
11581
  return { errorType: 'context_refusal' };
11567
11582
  }
11568
11583
  return {};
@@ -401,6 +401,22 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
401
401
  required: [],
402
402
  },
403
403
  },
404
+ {
405
+ name: 'journal',
406
+ description:
407
+ 'Write an entry in your private journal. The entry stays in your own context and ' +
408
+ 'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' +
409
+ 'than a line that you want to keep for yourself — reflections, what you decided and ' +
410
+ 'why, notes for later. It does not end your turn and does not affect where ordinary ' +
411
+ 'text is routed; to end the turn without replying, call skip_reply afterwards.',
412
+ inputSchema: {
413
+ type: 'object' as const,
414
+ properties: {
415
+ content: { type: 'string', description: 'The journal entry (private; not sent anywhere).' },
416
+ },
417
+ required: ['content'],
418
+ },
419
+ },
404
420
  {
405
421
  name: 'skip_reply',
406
422
  description:
@@ -414,7 +430,9 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
414
430
  properties: {
415
431
  reason: {
416
432
  type: 'string',
417
- description: 'Optional private note on why you are not replying (not sent anywhere).',
433
+ description:
434
+ 'Optional ONE short line on why you are not replying (private; not sent anywhere). ' +
435
+ 'Keep it under ~100 characters — put anything longer in journal() first.',
418
436
  },
419
437
  wake_in_seconds: {
420
438
  type: 'number',
@@ -773,7 +791,8 @@ export class ChannelRegistry {
773
791
  }
774
792
  }
775
793
 
776
- // Process added channels (validate per-descriptor, register, reconcile)
794
+ // Process added channels (validate per-descriptor, register; reconcile
795
+ // after responding, below)
777
796
  const accepted: typeof params.added = [];
778
797
  if (params.added) {
779
798
  for (const channel of params.added) {
@@ -792,10 +811,18 @@ export class ChannelRegistry {
792
811
  addedResults.push({ id: channel.id, accepted: true });
793
812
  accepted.push(channel);
794
813
  }
795
- if (accepted.length > 0) await this.reconcileChannels(serverId, accepted);
796
814
  }
815
+
816
+ // Respond before reconciliation, as handleRegister does. A server that
817
+ // announces from inside a request it is serving (a tool that refreshes
818
+ // or subscribes) cannot read the channels/open or channels/close that
819
+ // reconciling sends until this response arrives; reconciling first
820
+ // deadlocks both sides until one times out (#160). The verdicts are
821
+ // settled above, so nothing in the response depends on reconciling.
797
822
  responder?.respond({ results: addedResults });
798
823
 
824
+ if (accepted.length > 0) await this.reconcileChannels(serverId, accepted);
825
+
799
826
  this.emitTraceFn({
800
827
  type: 'mcpl:channels-changed',
801
828
  serverId,
@@ -1218,6 +1245,9 @@ export class ChannelRegistry {
1218
1245
  case 'think':
1219
1246
  return this.handleToolThink(input as { content?: string });
1220
1247
 
1248
+ case 'journal':
1249
+ return this.handleToolJournal(input as { content?: string });
1250
+
1221
1251
  case 'skip_reply':
1222
1252
  return this.handleToolSkipReply(input as { reason?: string; wake_in_seconds?: number });
1223
1253
 
@@ -2670,6 +2700,28 @@ export class ChannelRegistry {
2670
2700
  };
2671
2701
  }
2672
2702
 
2703
+ /**
2704
+ * Handle the synthesized `journal` tool — a private place for long-form
2705
+ * notes. Sends nothing, does not end the turn, does not touch prose routing.
2706
+ *
2707
+ * Why it exists (sill, 2026-09-19): residents were keeping 2–3KB diaries in
2708
+ * `skip_reply.reason`. Long prose in a private-REASONING tool argument
2709
+ * (`skip_reply.reason`, `think.content`) makes replayed history read as a
2710
+ * reasoning trace, and every memory-compression request over it is refused
2711
+ * `reasoning_extraction` regardless of content; the same prose in a
2712
+ * note-taking tool passes (canary record: context-manager
2713
+ * `tool-prose-hoist.ts`, whose fallback rung rewrites old history into calls
2714
+ * to THIS tool — so the result wording below is mirrored there as
2715
+ * DEFAULT_TOOL_PROSE_RESULT; keep the two in step).
2716
+ */
2717
+ private handleToolJournal(_input: { content?: string }): ToolResult {
2718
+ return {
2719
+ success: true,
2720
+ // No echo: the entry is already in the tool_use block.
2721
+ data: { recorded: true, note: 'Journal entry recorded (private — not sent anywhere).' },
2722
+ };
2723
+ }
2724
+
2673
2725
  /**
2674
2726
  * Handle the synthesized `skip_reply` tool — the deliberate "stay silent"
2675
2727
  * signal. A no-op as far as any surface is concerned (sends nothing); its
@@ -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
+ }