@animalabs/agent-framework 0.14.0 → 0.15.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 (62) hide show
  1. package/dist/src/agent.d.ts +10 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +39 -0
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +5 -2
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +126 -36
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/modules/history/index.d.ts +69 -0
  10. package/dist/src/modules/history/index.d.ts.map +1 -0
  11. package/dist/src/modules/history/index.js +519 -0
  12. package/dist/src/modules/history/index.js.map +1 -0
  13. package/dist/src/modules/history/search-regex-worker.d.ts +23 -0
  14. package/dist/src/modules/history/search-regex-worker.d.ts.map +1 -0
  15. package/dist/src/modules/history/search-regex-worker.js +57 -0
  16. package/dist/src/modules/history/search-regex-worker.js.map +1 -0
  17. package/dist/src/modules/index.d.ts +1 -0
  18. package/dist/src/modules/index.d.ts.map +1 -1
  19. package/dist/src/modules/index.js +1 -0
  20. package/dist/src/modules/index.js.map +1 -1
  21. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  22. package/dist/src/modules/workspace/index.js +42 -1
  23. package/dist/src/modules/workspace/index.js.map +1 -1
  24. package/dist/src/modules/workspace/types.d.ts +5 -0
  25. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  26. package/dist/src/modules/workspace/types.js.map +1 -1
  27. package/dist/src/types/trace.d.ts +4 -0
  28. package/dist/src/types/trace.d.ts.map +1 -1
  29. package/dist/test/history-module.test.d.ts +2 -0
  30. package/dist/test/history-module.test.d.ts.map +1 -0
  31. package/dist/test/history-module.test.js +449 -0
  32. package/dist/test/history-module.test.js.map +1 -0
  33. package/dist/test/kv-unified-open-flight-retry.test.d.ts +2 -0
  34. package/dist/test/kv-unified-open-flight-retry.test.d.ts.map +1 -0
  35. package/dist/test/kv-unified-open-flight-retry.test.js +189 -0
  36. package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -0
  37. package/dist/test/kv-unified-wiring.test.js +39 -0
  38. package/dist/test/kv-unified-wiring.test.js.map +1 -1
  39. package/dist/test/puppet-tool-call.test.js +113 -1
  40. package/dist/test/puppet-tool-call.test.js.map +1 -1
  41. package/dist/test/puppet-turn-reservation.test.d.ts +2 -0
  42. package/dist/test/puppet-turn-reservation.test.d.ts.map +1 -0
  43. package/dist/test/puppet-turn-reservation.test.js +139 -0
  44. package/dist/test/puppet-turn-reservation.test.js.map +1 -0
  45. package/dist/test/save-recent-image.test.js +29 -0
  46. package/dist/test/save-recent-image.test.js.map +1 -1
  47. package/dist/test/tool-result-spill.test.js +114 -0
  48. package/dist/test/tool-result-spill.test.js.map +1 -1
  49. package/dist/test/workspace-read-text.test.d.ts +2 -0
  50. package/dist/test/workspace-read-text.test.d.ts.map +1 -0
  51. package/dist/test/workspace-read-text.test.js +101 -0
  52. package/dist/test/workspace-read-text.test.js.map +1 -0
  53. package/dist/tsconfig.tsbuildinfo +1 -1
  54. package/package.json +3 -3
  55. package/src/agent.ts +41 -0
  56. package/src/framework.ts +132 -43
  57. package/src/modules/history/index.ts +613 -0
  58. package/src/modules/history/search-regex-worker.ts +76 -0
  59. package/src/modules/index.ts +2 -0
  60. package/src/modules/workspace/index.ts +42 -1
  61. package/src/modules/workspace/types.ts +5 -0
  62. package/src/types/trace.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/agent-framework",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,8 +46,8 @@
46
46
  "author": "Anima Research",
47
47
  "license": "MIT",
48
48
  "dependencies": {
49
- "@animalabs/chronicle": "^0.3.0",
50
- "@animalabs/context-manager": "^0.8.0",
49
+ "@animalabs/chronicle": "^0.4.0",
50
+ "@animalabs/context-manager": "^0.9.2",
51
51
  "@animalabs/membrane": "^0.5.78",
52
52
  "chokidar": "^4.0.3",
53
53
  "discord.js": "^14.25.1",
package/src/agent.ts CHANGED
@@ -99,6 +99,11 @@ export class Agent {
99
99
  private _state: AgentState = { status: 'idle' };
100
100
  private _inferenceStartedAt = 0;
101
101
  private _streamId = 0;
102
+ /** kv-unified receipt flights opened by the CURRENT activation and not yet
103
+ * settled by a usage event. Held on the agent (not only in the stream's
104
+ * closure) so the next activation can close whatever its predecessor left
105
+ * open — see failOpenKvSubmissions. */
106
+ private kvOpenQueue: Array<{ submissionId: string; wireReceipt: CacheWireReceipt }> | null = null;
102
107
  lastStreamInputTokens = 0;
103
108
  /** Real prefix size of the last usage event: fresh + cache creation +
104
109
  * cache read. THE window-shaped number — `lastStreamInputTokens` alone
@@ -730,6 +735,19 @@ export class Agent {
730
735
  throw new Error(`Agent ${this.name} cannot start stream in state ${this._state.status}`);
731
736
  }
732
737
 
738
+ // A new activation supersedes every receipt flight the previous one left
739
+ // open. Membrane fires the kv-unified wire receipt per provider attempt,
740
+ // BEFORE the adapter call; only that attempt's usage event settles it.
741
+ // When the attempt dies without usage (transport error, idle timeout,
742
+ // framework cancel for a budget restart or endTurn) the flight stays open
743
+ // until the old driveStream's `finally` runs — and every successor path
744
+ // (error-policy retry, budget restart, the next wake after an abort)
745
+ // starts the new stream BEFORE that `finally`. The successor's first
746
+ // receipt then met the ledger's single-flight guard and failed the
747
+ // recovery itself: "kv-unified submission devops:46:…:4 is still in
748
+ // flight" (devops agent, 2026-09-16 07:33Z, no provider error logged).
749
+ this.failOpenKvSubmissions();
750
+
733
751
  this._streamId++;
734
752
  this._inferenceStartedAt = Date.now();
735
753
  this.lastStreamInputTokens = 0;
@@ -753,6 +771,7 @@ export class Agent {
753
771
  const kvQueue: Array<{ submissionId: string; wireReceipt: CacheWireReceipt }> = [];
754
772
  let kvCall = 0;
755
773
  if (kvEnabled) {
774
+ this.kvOpenQueue = kvQueue;
756
775
  const layoutHash = stableHash(request.messages);
757
776
  const kvRequest = request as NormalizedRequest & KvUnifiedRequestHooks;
758
777
  kvRequest.onCacheWireReceipt = (receipt) => {
@@ -791,6 +810,28 @@ export class Agent {
791
810
  };
792
811
  }
793
812
 
813
+ /** Close every kv-unified receipt flight the previous activation left
814
+ * unsettled. Idempotent: the framework's driveStream `finally` drains the
815
+ * same per-stream queue, so whichever runs first empties it and the other
816
+ * finds nothing. Failing an already-settled id is a no-op in the ledger. */
817
+ private failOpenKvSubmissions(): void {
818
+ const open = this.kvOpenQueue?.splice(0) ?? [];
819
+ this.kvOpenQueue = null;
820
+ if (open.length === 0) return;
821
+ const strategy = (this.contextManager as unknown as { getStrategy?: () => unknown })
822
+ .getStrategy?.() as { reportKvUnifiedFailed?: (submissionId: string) => void } | undefined;
823
+ for (const { submissionId } of open) {
824
+ console.error(
825
+ `[kv-unified] ${this.name}: closing receipt flight ${submissionId} left open by the previous activation`,
826
+ );
827
+ try {
828
+ strategy?.reportKvUnifiedFailed?.(submissionId);
829
+ } catch (err) {
830
+ console.error(`[kv-unified] ${this.name}: could not close flight ${submissionId}:`, err);
831
+ }
832
+ }
833
+ }
834
+
794
835
  /**
795
836
  * Transition to waiting_for_tools when stream yields tool calls.
796
837
  * Called by framework's driveStream.
package/src/framework.ts CHANGED
@@ -6132,6 +6132,23 @@ export class AgentFramework {
6132
6132
  this.releasePrimaryProviderGate(agent.name);
6133
6133
  return;
6134
6134
  }
6135
+ // The scheduler's turn-alive test passed BEFORE the park; the wait
6136
+ // is the one gap in which another turn-alive holder can take the
6137
+ // agent (puppetToolCall's reservation, #145). Never replace its
6138
+ // token — the re-entry would set ours over it, and a short turn
6139
+ // here ends and flushes before the puppeted tool returns, leaving
6140
+ // its pair queued behind a flush that already happened. Give
6141
+ // admission back and hand the wake to the scheduler, which keeps
6142
+ // it queued while the agent is turn-alive and starts it normally
6143
+ // once the holder releases.
6144
+ if (this.activeTurnTokens.has(agent.name)) {
6145
+ this.releasePrimaryProviderGate(agent.name);
6146
+ this.pendingRequests.push(trigger ?? {
6147
+ agentName: agent.name, reason: 'provider-admission:requeue', source: 'scheduler', timestamp: Date.now(),
6148
+ });
6149
+ console.error(`[provider-admission] ${agent.name}: turn-alive while parked — wake requeued, not started`);
6150
+ return;
6151
+ }
6135
6152
  await this.startAgentStream(agent, trigger, attempt, true);
6136
6153
  }).catch((error) => {
6137
6154
  this.releasePrimaryProviderGate(agent.name);
@@ -7786,8 +7803,11 @@ export class AgentFramework {
7786
7803
  * affordance to a model that cannot find it (older models especially).
7787
7804
  *
7788
7805
  * Semantics:
7789
- * - Refused unless the agent is idle: puppeting mid-turn would corrupt
7790
- * the turn state machine and the live stream's wire ordering.
7806
+ * - Refused unless the agent is idle with no turn alive: puppeting
7807
+ * mid-turn would corrupt the turn state machine and the live stream's
7808
+ * wire ordering. For its own duration the puppet HOLDS the turn-alive
7809
+ * marker (wakes requeue, cross-turn writers defer), so a wake landing
7810
+ * during the tool's execution cannot start a turn underneath the pair.
7791
7811
  * - Refused for tools outside the agent's own surface (canUseTool): the
7792
7812
  * stored pair must be an act the agent could genuinely have taken.
7793
7813
  * - The call executes FOR REAL through the shared dispatch (MCPL,
@@ -7811,9 +7831,13 @@ export class AgentFramework {
7811
7831
  ): Promise<{ toolUseId: string; result: ToolResult }> {
7812
7832
  const agent = this.agents.get(agentName);
7813
7833
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
7814
- if (agent.state.status !== 'idle') {
7834
+ // Idle AND no turn alive: status reads 'idle' from dequeue until the
7835
+ // stream registers, and again while a turn's teardown is pending
7836
+ // (the scheduler's own busy test, 'idle+turn-alive').
7837
+ if (agent.state.status !== 'idle' || this.activeTurnTokens.has(agentName)) {
7838
+ const shown = agent.state.status === 'idle' ? 'idle+turn-alive' : agent.state.status;
7815
7839
  throw new Error(
7816
- `puppet refused: agent ${agentName} is ${agent.state.status} (requires idle — ` +
7840
+ `puppet refused: agent ${agentName} is ${shown} (requires idle — ` +
7817
7841
  `injecting a turn under an active stream corrupts wire ordering)`,
7818
7842
  );
7819
7843
  }
@@ -7833,44 +7857,94 @@ export class AgentFramework {
7833
7857
  for (let i = 0; i < 22; i++) suffix += alphabet[Math.floor(Math.random() * alphabet.length)];
7834
7858
  const toolUseId = `toolu_01${suffix}`;
7835
7859
 
7836
- const started = Date.now();
7837
- const result = await this.executeToolCall({
7838
- id: toolUseId,
7839
- name: toolName,
7840
- input,
7841
- callerAgentName: agentName,
7842
- });
7843
- const durationMs = Date.now() - started;
7844
-
7845
- // Store the pair through the same shapes the ordinary path uses. Build
7846
- // the result blocks BEFORE storing the tool_use: the spill path awaits,
7847
- // and a message arriving during that await must land before the pair,
7848
- // never between tool_use and its tool_result. The two addMessage calls
7849
- // below are synchronous and adjacent — nothing can interleave.
7850
- const { blocks } = await this.buildStoredToolResultContent(
7851
- agentName,
7852
- [{ id: toolUseId, name: toolName, input, result, durationMs }],
7853
- this.resolveToolResultInlineCap(agent).cap,
7854
- );
7855
- const cm = agent.getContextManager();
7856
- cm.addMessage(agentName, [
7857
- { type: 'tool_use', id: toolUseId, name: toolName, input } as ContentBlock,
7858
- ]);
7859
- cm.addMessage('user', blocks);
7860
+ // Reserve the agent against turn start for the WHOLE operation, before
7861
+ // the first await (#145). The idle check above is a point in time; the
7862
+ // tool execution and the result build both await, and a wake arriving
7863
+ // in either gap used to start a real turn — then the pair below wrote
7864
+ // straight through the context manager into a turn that never saw it,
7865
+ // the exact wire-order corruption this method exists to avoid. Holding
7866
+ // a turn token is the same invariant the scheduler respects (turn-alive
7867
+ // requeues wakes) and the addMessage guard reads (cross-turn writers
7868
+ // defer), so for the duration this behaves like a turn with no stream.
7869
+ // Token-matched release in finally, same leak-proofing as
7870
+ // startAgentStream: a token nobody clears wedges the agent.
7871
+ const turnToken = this.nextTurnToken++;
7872
+ this.activeTurnTokens.set(agentName, turnToken);
7873
+ let ownedToEnd = false;
7874
+ try {
7875
+ const started = Date.now();
7876
+ const result = await this.executeToolCall({
7877
+ id: toolUseId,
7878
+ name: toolName,
7879
+ input,
7880
+ callerAgentName: agentName,
7881
+ });
7882
+ const durationMs = Date.now() - started;
7883
+
7884
+ // Store the pair through the same shapes the ordinary path uses. Build
7885
+ // the result blocks BEFORE storing the tool_use: the spill path awaits.
7886
+ // A message arriving during either await is deferred by the turn
7887
+ // token (as during a real tool call) and lands AFTER the pair, never
7888
+ // between tool_use and its tool_result. The two addMessage calls
7889
+ // below are synchronous and adjacent — nothing can interleave.
7890
+ const { blocks } = await this.buildStoredToolResultContent(
7891
+ agentName,
7892
+ [{ id: toolUseId, name: toolName, input, result, durationMs }],
7893
+ this.resolveToolResultInlineCap(agent).cap,
7894
+ );
7895
+ const toolUse: ContentBlock[] = [
7896
+ { type: 'tool_use', id: toolUseId, name: toolName, input } as ContentBlock,
7897
+ ];
7898
+ // Defensive: no path replaces a live token any more (the provider-
7899
+ // admission re-entry re-tests turn-alive and requeues instead), but
7900
+ // if one ever does, the side effect has happened and the pair must
7901
+ // still be stored — not under that turn: queue it as a unit for the
7902
+ // next flush (drainDeferredFor keeps order; the tool_result entry is
7903
+ // never split from its tool_use because both ride the same drain).
7904
+ // The finally below flushes it itself if that turn is already over.
7905
+ ownedToEnd = this.activeTurnTokens.get(agentName) === turnToken;
7906
+ if (ownedToEnd) {
7907
+ const cm = agent.getContextManager();
7908
+ cm.addMessage(agentName, toolUse);
7909
+ cm.addMessage('user', blocks);
7910
+ } else {
7911
+ this.deferredMessages.push(
7912
+ { participant: agentName, content: toolUse, forAgent: agentName },
7913
+ { participant: 'user', content: blocks, forAgent: agentName },
7914
+ );
7915
+ }
7860
7916
 
7861
- this.emitTrace({
7862
- type: 'puppet:tool-call',
7863
- agentName,
7864
- toolName,
7865
- toolUseId,
7866
- isError: !!result.isError,
7867
- durationMs,
7868
- });
7869
- console.log(
7870
- `[puppet] ${agentName}: ${toolName} → ${result.isError ? 'ERROR' : 'ok'} ` +
7871
- `in ${durationMs}ms (${toolUseId})`,
7872
- );
7873
- return { toolUseId, result };
7917
+ this.emitTrace({
7918
+ type: 'puppet:tool-call',
7919
+ agentName,
7920
+ toolName,
7921
+ toolUseId,
7922
+ isError: !!result.isError,
7923
+ durationMs,
7924
+ ...(ownedToEnd ? {} : { deferred: true }),
7925
+ });
7926
+ console.log(
7927
+ `[puppet] ${agentName}: ${toolName} → ${result.isError ? 'ERROR' : 'ok'} ` +
7928
+ `in ${durationMs}ms (${toolUseId})${ownedToEnd ? '' : ' — deferred behind a live turn'}`,
7929
+ );
7930
+ return { toolUseId, result };
7931
+ } finally {
7932
+ if (this.activeTurnTokens.get(agentName) === turnToken) this.activeTurnTokens.delete(agentName);
7933
+ // Messages deferred while we held the token land now, after the pair
7934
+ // — the same end-of-turn flush driveStream performs, under the same
7935
+ // tool-cycle guard. (No wake is requested: the agent sees the pair,
7936
+ // and anything that arrived meanwhile, on its next turn.) Keyed on
7937
+ // "no turn alive", not on token ownership: if a turn did replace us
7938
+ // and has already ended, its flush ran before our pair was queued —
7939
+ // nobody else will flush it, so we do.
7940
+ if (!this.activeTurnTokens.has(agentName)
7941
+ && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
7942
+ for (const msg of this.drainDeferredFor(agentName)) {
7943
+ this.addMessage(msg.participant, msg.content, msg.metadata,
7944
+ msg.forAgent ? { forAgent: msg.forAgent } : undefined);
7945
+ }
7946
+ }
7947
+ }
7874
7948
  }
7875
7949
 
7876
7950
  private async executeToolCallFrom(call: ToolCall, origin: ChannelToolOrigin): Promise<ToolResult> {
@@ -8417,10 +8491,16 @@ export class AgentFramework {
8417
8491
  try {
8418
8492
  const result = await workspace.writeBinary(path, Buffer.from(content, 'utf8'), 'text/plain');
8419
8493
  if (result.success) {
8494
+ // JSON serialization can expand each read-back code unit to six
8495
+ // chars (\\u0000). Leave room for the page's metadata and a possible
8496
+ // extra code unit at a surrogate-pair boundary.
8497
+ const pageOverhead = JSON.stringify(path).length + 256;
8498
+ const readLimit = Math.max(1, Math.min(2000, Math.floor((cap - pageOverhead) / 6) - 1));
8420
8499
  return {
8421
8500
  text: head
8422
- + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}. `
8423
- + 'Read/grep it with your file tools, or raise the inline cap via '
8501
+ + `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
8502
+ + `. Page with workspace--read ${JSON.stringify({ path, offsetChars: 0, limitChars: readLimit })}; `
8503
+ + 'use nextOffsetChars as offsetChars until null, keeping limitChars. Or raise the inline cap via '
8424
8504
  + 'agent_settings update tool_result_inline_max_chars.]'
8425
8505
  + kept,
8426
8506
  filePath: path,
@@ -8773,11 +8853,18 @@ export class AgentFramework {
8773
8853
  // Blob threshold: 10KB - typical context-heavy requests exceed this
8774
8854
  const BLOB_THRESHOLD = 10000;
8775
8855
 
8856
+ // Both branches persist the JSON view, never the live object: a compiled
8857
+ // request carries non-JSON members (the kv-unified `onCacheWireReceipt`
8858
+ // hook is a function) and Chronicle's JSON bridge rejects those with
8859
+ // "JS functions cannot be represented as a serde_json::Value" — which,
8860
+ // thrown from the failure path, masked the failure being logged.
8776
8861
  if (entry.request && typeof entry.request === 'object') {
8777
8862
  const requestJson = JSON.stringify(entry.request);
8778
8863
  if (requestJson.length > BLOB_THRESHOLD) {
8779
8864
  const blobId = this.store.storeBlob(Buffer.from(requestJson), 'application/json');
8780
8865
  entryToStore.request = { blobId };
8866
+ } else {
8867
+ entryToStore.request = JSON.parse(requestJson);
8781
8868
  }
8782
8869
  }
8783
8870
 
@@ -8786,6 +8873,8 @@ export class AgentFramework {
8786
8873
  if (responseJson.length > BLOB_THRESHOLD) {
8787
8874
  const blobId = this.store.storeBlob(Buffer.from(responseJson), 'application/json');
8788
8875
  entryToStore.response = { blobId };
8876
+ } else {
8877
+ entryToStore.response = JSON.parse(responseJson);
8789
8878
  }
8790
8879
  }
8791
8880