@illuma-ai/agents 1.1.5 → 1.1.7

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 (136) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +1 -0
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/events.cjs +1 -0
  4. package/dist/cjs/events.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +11 -8
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/graphs/MultiAgentGraph.cjs +1 -0
  8. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  9. package/dist/cjs/llm/openai/index.cjs +1 -0
  10. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  11. package/dist/cjs/llm/providers.cjs +1 -0
  12. package/dist/cjs/llm/providers.cjs.map +1 -1
  13. package/dist/cjs/main.cjs +35 -13
  14. package/dist/cjs/main.cjs.map +1 -1
  15. package/dist/cjs/messages/content.cjs +1 -0
  16. package/dist/cjs/messages/content.cjs.map +1 -1
  17. package/dist/cjs/messages/core.cjs +1 -0
  18. package/dist/cjs/messages/core.cjs.map +1 -1
  19. package/dist/cjs/messages/dedup.cjs +1 -0
  20. package/dist/cjs/messages/dedup.cjs.map +1 -1
  21. package/dist/cjs/messages/format.cjs +1 -0
  22. package/dist/cjs/messages/format.cjs.map +1 -1
  23. package/dist/cjs/messages/prune.cjs +1 -0
  24. package/dist/cjs/messages/prune.cjs.map +1 -1
  25. package/dist/cjs/messages/tools.cjs +1 -0
  26. package/dist/cjs/messages/tools.cjs.map +1 -1
  27. package/dist/cjs/run.cjs +1 -0
  28. package/dist/cjs/run.cjs.map +1 -1
  29. package/dist/cjs/schemas/validate.cjs +1 -0
  30. package/dist/cjs/schemas/validate.cjs.map +1 -1
  31. package/dist/cjs/splitStream.cjs +1 -0
  32. package/dist/cjs/splitStream.cjs.map +1 -1
  33. package/dist/cjs/stream.cjs +1 -0
  34. package/dist/cjs/stream.cjs.map +1 -1
  35. package/dist/cjs/tools/AskUser.cjs +1 -0
  36. package/dist/cjs/tools/AskUser.cjs.map +1 -1
  37. package/dist/cjs/tools/CodeExecutor.cjs +1 -0
  38. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  39. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -0
  40. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  41. package/dist/cjs/tools/ToolNode.cjs +12 -8
  42. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  43. package/dist/cjs/tools/ToolSearch.cjs +1 -0
  44. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  45. package/dist/cjs/tools/approval/constants.cjs +107 -0
  46. package/dist/cjs/tools/approval/constants.cjs.map +1 -0
  47. package/dist/cjs/tools/handlers.cjs +1 -0
  48. package/dist/cjs/tools/handlers.cjs.map +1 -1
  49. package/dist/cjs/tools/search/tool.cjs +1 -0
  50. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  51. package/dist/cjs/utils/fileManifest.cjs.map +1 -1
  52. package/dist/cjs/utils/handlers.cjs +1 -0
  53. package/dist/cjs/utils/handlers.cjs.map +1 -1
  54. package/dist/cjs/utils/llm.cjs +1 -0
  55. package/dist/cjs/utils/llm.cjs.map +1 -1
  56. package/dist/cjs/utils/title.cjs +1 -0
  57. package/dist/cjs/utils/title.cjs.map +1 -1
  58. package/dist/cjs/utils/toolCallContinuation.cjs +1 -0
  59. package/dist/cjs/utils/toolCallContinuation.cjs.map +1 -1
  60. package/dist/cjs/utils/toolDiscoveryCache.cjs +1 -0
  61. package/dist/cjs/utils/toolDiscoveryCache.cjs.map +1 -1
  62. package/dist/esm/agents/AgentContext.mjs +1 -0
  63. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  64. package/dist/esm/events.mjs +1 -0
  65. package/dist/esm/events.mjs.map +1 -1
  66. package/dist/esm/graphs/Graph.mjs +11 -8
  67. package/dist/esm/graphs/Graph.mjs.map +1 -1
  68. package/dist/esm/graphs/MultiAgentGraph.mjs +1 -0
  69. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  70. package/dist/esm/llm/openai/index.mjs +1 -0
  71. package/dist/esm/llm/openai/index.mjs.map +1 -1
  72. package/dist/esm/llm/providers.mjs +1 -0
  73. package/dist/esm/llm/providers.mjs.map +1 -1
  74. package/dist/esm/main.mjs +1 -0
  75. package/dist/esm/main.mjs.map +1 -1
  76. package/dist/esm/messages/content.mjs +1 -0
  77. package/dist/esm/messages/content.mjs.map +1 -1
  78. package/dist/esm/messages/core.mjs +1 -0
  79. package/dist/esm/messages/core.mjs.map +1 -1
  80. package/dist/esm/messages/dedup.mjs +1 -0
  81. package/dist/esm/messages/dedup.mjs.map +1 -1
  82. package/dist/esm/messages/format.mjs +1 -0
  83. package/dist/esm/messages/format.mjs.map +1 -1
  84. package/dist/esm/messages/prune.mjs +1 -0
  85. package/dist/esm/messages/prune.mjs.map +1 -1
  86. package/dist/esm/messages/tools.mjs +1 -0
  87. package/dist/esm/messages/tools.mjs.map +1 -1
  88. package/dist/esm/run.mjs +1 -0
  89. package/dist/esm/run.mjs.map +1 -1
  90. package/dist/esm/schemas/validate.mjs +1 -0
  91. package/dist/esm/schemas/validate.mjs.map +1 -1
  92. package/dist/esm/splitStream.mjs +1 -0
  93. package/dist/esm/splitStream.mjs.map +1 -1
  94. package/dist/esm/stream.mjs +1 -0
  95. package/dist/esm/stream.mjs.map +1 -1
  96. package/dist/esm/tools/AskUser.mjs +1 -0
  97. package/dist/esm/tools/AskUser.mjs.map +1 -1
  98. package/dist/esm/tools/CodeExecutor.mjs +1 -0
  99. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  100. package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -0
  101. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  102. package/dist/esm/tools/ToolNode.mjs +12 -8
  103. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  104. package/dist/esm/tools/ToolSearch.mjs +1 -0
  105. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  106. package/dist/esm/tools/approval/constants.mjs +105 -0
  107. package/dist/esm/tools/approval/constants.mjs.map +1 -0
  108. package/dist/esm/tools/handlers.mjs +1 -0
  109. package/dist/esm/tools/handlers.mjs.map +1 -1
  110. package/dist/esm/tools/search/tool.mjs +1 -0
  111. package/dist/esm/tools/search/tool.mjs.map +1 -1
  112. package/dist/esm/utils/fileManifest.mjs.map +1 -1
  113. package/dist/esm/utils/handlers.mjs +1 -0
  114. package/dist/esm/utils/handlers.mjs.map +1 -1
  115. package/dist/esm/utils/llm.mjs +1 -0
  116. package/dist/esm/utils/llm.mjs.map +1 -1
  117. package/dist/esm/utils/title.mjs +1 -0
  118. package/dist/esm/utils/title.mjs.map +1 -1
  119. package/dist/esm/utils/toolCallContinuation.mjs +1 -0
  120. package/dist/esm/utils/toolCallContinuation.mjs.map +1 -1
  121. package/dist/esm/utils/toolDiscoveryCache.mjs +1 -0
  122. package/dist/esm/utils/toolDiscoveryCache.mjs.map +1 -1
  123. package/dist/types/common/index.d.ts +1 -0
  124. package/dist/types/tools/approval/constants.d.ts +79 -0
  125. package/dist/types/types/tools.d.ts +4 -1
  126. package/package.json +1 -1
  127. package/src/common/index.ts +1 -0
  128. package/src/graphs/Graph.ts +42 -27
  129. package/src/graphs/gapFeatures.test.ts +65 -22
  130. package/src/tools/ToolNode.ts +11 -12
  131. package/src/tools/__tests__/ToolApproval.test.ts +100 -46
  132. package/src/tools/__tests__/ToolNode.hitl.test.ts +3 -2
  133. package/src/tools/approval/__tests__/constants.test.ts +74 -0
  134. package/src/tools/approval/constants.ts +109 -0
  135. package/src/types/tools.ts +5 -1
  136. package/src/utils/fileManifest.ts +3 -1
@@ -538,14 +538,21 @@ describe('Proactive Summarization — Context Pressure', () => {
538
538
  indexTokenCountMap[String(i)] = tokensPerMsg;
539
539
  }
540
540
 
541
- const utilization = getContextUtilization(indexTokenCountMap, 0, maxContextTokens);
541
+ const utilization = getContextUtilization(
542
+ indexTokenCountMap,
543
+ 0,
544
+ maxContextTokens
545
+ );
542
546
  const threshold = PROACTIVE_SUMMARY_THRESHOLD * 100; // 80
543
547
 
544
548
  expect(utilization).toBeGreaterThanOrEqual(threshold);
545
549
  // At 82%, proactive summary should fire
546
550
  // But pruning should NOT have happened yet (context < 90% safety factor)
547
551
  const effectiveBudget = Math.floor(maxContextTokens * 0.9); // CONTEXT_SAFETY_FACTOR
548
- const totalTokens = Object.values(indexTokenCountMap).reduce((s, v) => (s ?? 0) + (v ?? 0), 0) as number;
552
+ const totalTokens = Object.values(indexTokenCountMap).reduce(
553
+ (s, v) => (s ?? 0) + (v ?? 0),
554
+ 0
555
+ ) as number;
549
556
  expect(totalTokens).toBeLessThan(effectiveBudget);
550
557
  });
551
558
 
@@ -559,7 +566,11 @@ describe('Proactive Summarization — Context Pressure', () => {
559
566
  indexTokenCountMap[String(i)] = tokensPerMsg;
560
567
  }
561
568
 
562
- const utilization = getContextUtilization(indexTokenCountMap, 0, maxContextTokens);
569
+ const utilization = getContextUtilization(
570
+ indexTokenCountMap,
571
+ 0,
572
+ maxContextTokens
573
+ );
563
574
  expect(utilization).toBeLessThan(PROACTIVE_SUMMARY_THRESHOLD * 100);
564
575
  });
565
576
 
@@ -622,7 +633,11 @@ describe('Proactive Summarization — Context Pressure', () => {
622
633
  '0': 210_000, // system + everything
623
634
  };
624
635
 
625
- const utilization = getContextUtilization(indexTokenCountMap, 0, maxContextTokens);
636
+ const utilization = getContextUtilization(
637
+ indexTokenCountMap,
638
+ 0,
639
+ maxContextTokens
640
+ );
626
641
  expect(utilization).toBeGreaterThan(100);
627
642
 
628
643
  // Even at 100%+, we use the existing cached summary — no error thrown
@@ -637,7 +652,10 @@ describe('Proactive Summarization — Context Pressure', () => {
637
652
 
638
653
  import { applyCalibration as _applyCalibration } from '@/utils/pruneCalibration';
639
654
  import { COMPACTION_RECENT_ROUNDS } from '@/common/constants';
640
- import { buildFileManifestBlock, FILE_MANIFEST_PREFIX } from '@/utils/fileManifest';
655
+ import {
656
+ buildFileManifestBlock,
657
+ FILE_MANIFEST_PREFIX,
658
+ } from '@/utils/fileManifest';
641
659
  import type { FileManifestEntry } from '@/types/graph';
642
660
 
643
661
  describe('Context Compaction — Windowed View (no message deletion)', () => {
@@ -655,7 +673,14 @@ describe('Context Compaction — Windowed View (no message deletion)', () => {
655
673
  tokenCounter: TokenCounter;
656
674
  fileManifest?: FileManifestEntry[];
657
675
  }) {
658
- const { messages, indexTokenCountMap, maxTokens, summary, tokenCounter, fileManifest } = opts;
676
+ const {
677
+ messages,
678
+ indexTokenCountMap,
679
+ maxTokens,
680
+ summary,
681
+ tokenCounter,
682
+ fileManifest,
683
+ } = opts;
659
684
 
660
685
  const systemMsg = messages[0]?.getType() === 'system' ? messages[0] : null;
661
686
  const systemTokens = systemMsg != null ? (indexTokenCountMap[0] ?? 0) : 0;
@@ -711,7 +736,11 @@ describe('Context Compaction — Windowed View (no message deletion)', () => {
711
736
 
712
737
  // Inject file manifest when files exist and messages are being compacted
713
738
  let fileManifestMsg: SystemMessage | null = null;
714
- if (fileManifest && fileManifest.length > 0 && compactedMessages.length > 0) {
739
+ if (
740
+ fileManifest &&
741
+ fileManifest.length > 0 &&
742
+ compactedMessages.length > 0
743
+ ) {
715
744
  const manifestBlock = buildFileManifestBlock(fileManifest);
716
745
  if (manifestBlock) {
717
746
  fileManifestMsg = new SystemMessage(manifestBlock);
@@ -721,7 +750,13 @@ describe('Context Compaction — Windowed View (no message deletion)', () => {
721
750
 
722
751
  view.push(...recentMessages);
723
752
 
724
- return { view, compactedMessages, recentMessages, usedTokens, fileManifestMsg };
753
+ return {
754
+ view,
755
+ compactedMessages,
756
+ recentMessages,
757
+ usedTokens,
758
+ fileManifestMsg,
759
+ };
725
760
  }
726
761
 
727
762
  it('builds a windowed view without deleting any messages', () => {
@@ -802,7 +837,11 @@ describe('Context Compaction — Windowed View (no message deletion)', () => {
802
837
  content: 'Let me search',
803
838
  tool_calls: [{ id: 'tc_1', name: 'web_search', args: {} }],
804
839
  }),
805
- new ToolMessage({ content: 'Search results', tool_call_id: 'tc_1', name: 'web_search' }),
840
+ new ToolMessage({
841
+ content: 'Search results',
842
+ tool_call_id: 'tc_1',
843
+ name: 'web_search',
844
+ }),
806
845
  new AIMessage('Based on the search results...'),
807
846
  new HumanMessage('latest question'),
808
847
  new AIMessage('latest answer'),
@@ -850,13 +889,14 @@ describe('Context Compaction — Windowed View (no message deletion)', () => {
850
889
 
851
890
  // Large summary eats into the budget
852
891
  const largeSummary = 'S'.repeat(1000); // ~250 tokens
853
- const { view: viewWithSummary, recentMessages: recentWithSummary } = buildWindowedView({
854
- messages,
855
- indexTokenCountMap,
856
- maxTokens: 800,
857
- summary: largeSummary,
858
- tokenCounter: simpleTokenCounter,
859
- });
892
+ const { view: viewWithSummary, recentMessages: recentWithSummary } =
893
+ buildWindowedView({
894
+ messages,
895
+ indexTokenCountMap,
896
+ maxTokens: 800,
897
+ summary: largeSummary,
898
+ tokenCounter: simpleTokenCounter,
899
+ });
860
900
 
861
901
  // Without summary — more recent messages fit
862
902
  const { recentMessages: recentWithout } = buildWindowedView({
@@ -872,9 +912,7 @@ describe('Context Compaction — Windowed View (no message deletion)', () => {
872
912
 
873
913
  it('with summary, limits window to last 2 rounds (not budget-filling)', () => {
874
914
  // 20 messages = 10 rounds. With summary, should only keep last 2 rounds (4 msgs).
875
- const messages: BaseMessage[] = [
876
- new SystemMessage('System prompt'),
877
- ];
915
+ const messages: BaseMessage[] = [new SystemMessage('System prompt')];
878
916
  for (let i = 0; i < 20; i++) {
879
917
  messages.push(
880
918
  i % 2 === 0
@@ -975,7 +1013,12 @@ describe('Context Compaction — Windowed View (no message deletion)', () => {
975
1013
 
976
1014
  const manifest: FileManifestEntry[] = [
977
1015
  { fileId: 'f1', filename: 'report.pdf', contentId: 'abc123' },
978
- { fileId: 'f2', filename: 'data.csv', contentId: 'def456', source: 'local' },
1016
+ {
1017
+ fileId: 'f2',
1018
+ filename: 'data.csv',
1019
+ contentId: 'def456',
1020
+ source: 'local',
1021
+ },
979
1022
  ];
980
1023
 
981
1024
  const { view, compactedMessages, fileManifestMsg } = buildWindowedView({
@@ -1001,8 +1044,8 @@ describe('Context Compaction — Windowed View (no message deletion)', () => {
1001
1044
 
1002
1045
  // View order: [system] + [summary] + [file manifest] + [recent messages]
1003
1046
  expect(view[0].getType()).toBe('system');
1004
- expect((view[1].content as string)).toContain('[Conversation Summary]');
1005
- expect((view[2].content as string)).toContain(FILE_MANIFEST_PREFIX);
1047
+ expect(view[1].content as string).toContain('[Conversation Summary]');
1048
+ expect(view[2].content as string).toContain(FILE_MANIFEST_PREFIX);
1006
1049
  // Recent messages follow
1007
1050
  expect(view.length).toBeGreaterThan(3);
1008
1051
  });
@@ -19,6 +19,7 @@ import type {
19
19
  import type { BaseMessage, AIMessage } from '@langchain/core/messages';
20
20
  import type { StructuredToolInterface } from '@langchain/core/tools';
21
21
  import type * as t from '@/types';
22
+ import { ExecutionContext } from './approval/constants';
22
23
  import { RunnableCallable } from '@/utils';
23
24
  import { processToolOutput } from '@/utils/toonFormat';
24
25
  import { safeDispatchCustomEvent } from '@/utils/events';
@@ -179,8 +180,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
179
180
  const { defaultPolicy, overrides, executionContext } =
180
181
  this.toolApprovalConfig;
181
182
 
182
- // Scheduled and browser executions bypass all approval checks
183
- if (executionContext === 'scheduled' || executionContext === 'browser') {
183
+ // Scheduled executions bypass all approval checks
184
+ if (executionContext === ExecutionContext.SCHEDULED) {
184
185
  return false;
185
186
  }
186
187
 
@@ -1001,22 +1002,20 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1001
1002
  (c) => !this.directToolNames!.has(c.name)
1002
1003
  );
1003
1004
 
1004
- const directOutputs: (BaseMessage | Command)[] =
1005
+ // Run direct tools and event tools in parallel — they are independent
1006
+ const [directOutputs, eventOutputs] = (await Promise.all([
1005
1007
  directCalls.length > 0
1006
- ? await Promise.all(
1007
- directCalls.map((call) => this.runTool(call, config))
1008
- )
1009
- : [];
1008
+ ? Promise.all(directCalls.map((call) => this.runTool(call, config)))
1009
+ : [],
1010
+ eventCalls.length > 0
1011
+ ? this.dispatchToolEvents(eventCalls, config)
1012
+ : [],
1013
+ ])) as [(BaseMessage | Command)[], ToolMessage[]];
1010
1014
 
1011
1015
  if (directCalls.length > 0 && directOutputs.length > 0) {
1012
1016
  this.handleRunToolCompletions(directCalls, directOutputs, config);
1013
1017
  }
1014
1018
 
1015
- const eventOutputs: ToolMessage[] =
1016
- eventCalls.length > 0
1017
- ? await this.dispatchToolEvents(eventCalls, config)
1018
- : [];
1019
-
1020
1019
  outputs = [...directOutputs, ...eventOutputs];
1021
1020
  } else {
1022
1021
  outputs = await Promise.all(
@@ -264,46 +264,14 @@ describe('HITL Tool Approval - Policy Evaluation', () => {
264
264
  );
265
265
  });
266
266
 
267
- test('browser execution context bypasses all approvals', async () => {
268
- // Browser extension has no HITL UI all approvals should be auto-granted
269
- const toolNode = new ToolNode({
270
- tools: [sendEmailTool],
271
- toolApprovalConfig: {
272
- defaultPolicy: 'always',
273
- executionContext: 'browser',
274
- },
275
- });
276
-
277
- const input = {
278
- messages: [
279
- new AIMessage({
280
- content: '',
281
- tool_calls: [
282
- {
283
- name: 'send_email',
284
- args: { to: 'user@example.com', subject: 'Test' },
285
- id: 'call_browser_1',
286
- type: 'tool_call' as const,
287
- },
288
- ],
289
- }),
290
- ],
291
- };
292
-
293
- // Should execute without interruption because context is 'browser'
294
- const result = await toolNode.invoke(input);
295
- expect(result.messages).toHaveLength(1);
296
- expect((result.messages[0] as ToolMessage).content).toContain('sent');
297
- });
298
-
299
- test('browser context bypasses custom function policy', async () => {
300
- // Even a custom function that always returns true should be skipped in browser context
267
+ test('scheduled context bypasses custom function policy', async () => {
268
+ // Even a custom function that always returns true should be skipped in scheduled context
301
269
  const alwaysRequire = (): boolean => true;
302
270
  const toolNode = new ToolNode({
303
271
  tools: [echoTool],
304
272
  toolApprovalConfig: {
305
273
  defaultPolicy: alwaysRequire,
306
- executionContext: 'browser',
274
+ executionContext: 'scheduled',
307
275
  },
308
276
  });
309
277
 
@@ -314,8 +282,8 @@ describe('HITL Tool Approval - Policy Evaluation', () => {
314
282
  tool_calls: [
315
283
  {
316
284
  name: 'echo',
317
- args: { message: 'browser test' },
318
- id: 'call_browser_2',
285
+ args: { message: 'scheduled test' },
286
+ id: 'call_sched_2',
319
287
  type: 'tool_call' as const,
320
288
  },
321
289
  ],
@@ -326,17 +294,17 @@ describe('HITL Tool Approval - Policy Evaluation', () => {
326
294
  const result = await toolNode.invoke(input);
327
295
  expect(result.messages).toHaveLength(1);
328
296
  expect((result.messages[0] as ToolMessage).content).toContain(
329
- 'Echo: browser test'
297
+ 'Echo: scheduled test'
330
298
  );
331
299
  });
332
300
 
333
- test('browser context bypasses per-tool overrides', async () => {
334
- // Even with an explicit 'always' override for the tool, browser context should skip it
301
+ test('scheduled context bypasses per-tool overrides', async () => {
302
+ // Even with an explicit 'always' override for the tool, scheduled context should skip it
335
303
  const toolNode = new ToolNode({
336
304
  tools: [searchTool],
337
305
  toolApprovalConfig: {
338
306
  defaultPolicy: 'never',
339
- executionContext: 'browser',
307
+ executionContext: 'scheduled',
340
308
  overrides: {
341
309
  search: 'always',
342
310
  },
@@ -350,8 +318,8 @@ describe('HITL Tool Approval - Policy Evaluation', () => {
350
318
  tool_calls: [
351
319
  {
352
320
  name: 'search',
353
- args: { query: 'browser override test' },
354
- id: 'call_browser_3',
321
+ args: { query: 'scheduled override test' },
322
+ id: 'call_sched_3',
355
323
  type: 'tool_call' as const,
356
324
  },
357
325
  ],
@@ -362,12 +330,12 @@ describe('HITL Tool Approval - Policy Evaluation', () => {
362
330
  const result = await toolNode.invoke(input);
363
331
  expect(result.messages).toHaveLength(1);
364
332
  expect((result.messages[0] as ToolMessage).content).toContain(
365
- 'Result for: browser override test'
333
+ 'Result for: scheduled override test'
366
334
  );
367
335
  });
368
336
 
369
- test('interactive context still requires approval (browser does not)', async () => {
370
- // Verify that 'interactive' context DOES require approval (contrasts with browser)
337
+ test('interactive context still requires approval (scheduled does not)', async () => {
338
+ // Verify that 'interactive' context DOES require approval (contrasts with scheduled)
371
339
  const approvalEvents: t.ToolApprovalEvent[] = [];
372
340
  const { compiled, config } = createTestGraph(
373
341
  [sendEmailTool],
@@ -709,6 +677,92 @@ describe('HITL Tool Approval - Event-Driven Flow', () => {
709
677
  expect(toolMessages[0].content.toString()).toContain('user@example.com');
710
678
  });
711
679
 
680
+ test('approval promise rejection is handled gracefully (host error)', async () => {
681
+ const toolNode = new ToolNode({
682
+ tools: [echoTool] as t.GenericTool[],
683
+ toolApprovalConfig: {
684
+ defaultPolicy: 'always',
685
+ executionContext: 'interactive',
686
+ },
687
+ });
688
+
689
+ const StateAnnotation = Annotation.Root({
690
+ messages: Annotation<BaseMessage[]>({
691
+ reducer: messagesStateReducer,
692
+ default: () => [],
693
+ }),
694
+ });
695
+
696
+ let callCount = 0;
697
+ const callModel = async (_state: {
698
+ messages: BaseMessage[];
699
+ }): Promise<{ messages: BaseMessage[] }> => {
700
+ callCount++;
701
+ if (callCount === 1) {
702
+ return {
703
+ messages: [
704
+ new AIMessage({
705
+ content: '',
706
+ tool_calls: [
707
+ {
708
+ name: 'echo',
709
+ args: { message: 'test' },
710
+ id: 'call_reject_1',
711
+ type: 'tool_call' as const,
712
+ },
713
+ ],
714
+ }),
715
+ ],
716
+ };
717
+ }
718
+ return { messages: [new AIMessage({ content: 'Recovered.' })] };
719
+ };
720
+
721
+ const routeMessage = (state: typeof StateAnnotation.State): string =>
722
+ toolsCondition(state, 'tools');
723
+
724
+ const workflow = new StateGraph(StateAnnotation)
725
+ .addNode('agent', callModel)
726
+ .addNode('tools', toolNode)
727
+ .addEdge(START, 'agent')
728
+ .addConditionalEdges('agent', routeMessage)
729
+ .addEdge('tools', 'agent');
730
+
731
+ const compiled = workflow.compile();
732
+ const config: Partial<RunnableConfig> & { version: 'v1' | 'v2' } = {
733
+ version: 'v2',
734
+ callbacks: [
735
+ {
736
+ handleCustomEvent: async (
737
+ eventName: string,
738
+ data: unknown
739
+ ): Promise<void> => {
740
+ if (eventName === GraphEvents.ON_TOOL_APPROVAL_REQUIRED) {
741
+ const event = data as t.ToolApprovalEvent;
742
+ // Simulate host-side error (e.g., stream disconnected)
743
+ event.reject(new Error('Stream closed before approval'));
744
+ }
745
+ },
746
+ },
747
+ ],
748
+ };
749
+
750
+ // The graph should handle rejection without crashing
751
+ const result = await compiled.invoke(
752
+ { messages: [new HumanMessage('Say hello')] },
753
+ config
754
+ );
755
+
756
+ // Should have a tool message with error content
757
+ const toolMessages = result.messages.filter(
758
+ (m: BaseMessage) => m._getType() === 'tool'
759
+ );
760
+ expect(toolMessages.length).toBeGreaterThan(0);
761
+ // Agent should continue after rejection
762
+ const lastMessage = result.messages[result.messages.length - 1];
763
+ expect(lastMessage._getType()).toBe('ai');
764
+ });
765
+
712
766
  test('multiple tool calls each get individual approval', async () => {
713
767
  const approvalEvents: t.ToolApprovalEvent[] = [];
714
768
 
@@ -125,15 +125,16 @@ describe('ToolNode HITL approval bypass', () => {
125
125
  );
126
126
  });
127
127
 
128
- it('returns false for all tools when executionContext is browser', () => {
128
+ it('returns false for all tools when executionContext is scheduled', () => {
129
129
  const node = createToolNode({
130
130
  toolApprovalConfig: {
131
131
  defaultPolicy: 'always',
132
- executionContext: 'browser',
132
+ executionContext: 'scheduled',
133
133
  },
134
134
  });
135
135
 
136
136
  expect(callRequiresApproval(node, 'content_tool')).toBe(false);
137
+ expect(callRequiresApproval(node, 'send_email')).toBe(false);
137
138
  });
138
139
  });
139
140
 
@@ -0,0 +1,74 @@
1
+ import {
2
+ ExecutionContext,
3
+ ApprovalPolicy,
4
+ ApprovalTier,
5
+ RiskLevel,
6
+ ActionCategory,
7
+ MCP_DELIMITER,
8
+ } from '../constants';
9
+
10
+ describe('HITL Approval Constants', () => {
11
+ describe('ExecutionContext', () => {
12
+ it('should have INTERACTIVE and SCHEDULED values', () => {
13
+ expect(ExecutionContext.INTERACTIVE).toBe('interactive');
14
+ expect(ExecutionContext.SCHEDULED).toBe('scheduled');
15
+ });
16
+
17
+ it('should only have 2 values (no dead "browser" context)', () => {
18
+ const values = Object.values(ExecutionContext);
19
+ expect(values).toHaveLength(2);
20
+ expect(values).toEqual(['interactive', 'scheduled']);
21
+ });
22
+ });
23
+
24
+ describe('ApprovalPolicy', () => {
25
+ it('should have ALWAYS and NEVER values', () => {
26
+ expect(ApprovalPolicy.ALWAYS).toBe('always');
27
+ expect(ApprovalPolicy.NEVER).toBe('never');
28
+ });
29
+ });
30
+
31
+ describe('ApprovalTier', () => {
32
+ it('should define the 3-tier evaluation pipeline', () => {
33
+ expect(ApprovalTier.AUTO_APPROVE).toBe('auto_approve');
34
+ expect(ApprovalTier.RULE_BASED).toBe('rule_based');
35
+ expect(ApprovalTier.KEYWORD_FALLBACK).toBe('keyword_fallback');
36
+ });
37
+ });
38
+
39
+ describe('RiskLevel', () => {
40
+ it('should define 5 risk levels from NONE to CRITICAL', () => {
41
+ expect(RiskLevel.NONE).toBe('none');
42
+ expect(RiskLevel.LOW).toBe('low');
43
+ expect(RiskLevel.MEDIUM).toBe('medium');
44
+ expect(RiskLevel.HIGH).toBe('high');
45
+ expect(RiskLevel.CRITICAL).toBe('critical');
46
+ });
47
+
48
+ it('should have exactly 5 values', () => {
49
+ expect(Object.values(RiskLevel)).toHaveLength(5);
50
+ });
51
+ });
52
+
53
+ describe('ActionCategory', () => {
54
+ it('should define action categories', () => {
55
+ expect(ActionCategory.READ).toBe('read');
56
+ expect(ActionCategory.WRITE).toBe('write');
57
+ expect(ActionCategory.DELETE).toBe('delete');
58
+ expect(ActionCategory.SEND).toBe('send');
59
+ expect(ActionCategory.EXECUTE).toBe('execute');
60
+ expect(ActionCategory.FINANCIAL).toBe('financial');
61
+ expect(ActionCategory.ACCOUNT).toBe('account');
62
+ });
63
+ });
64
+
65
+ describe('MCP_DELIMITER', () => {
66
+ it('should match Constants.mcp_delimiter from @ranger/data-provider', () => {
67
+ expect(MCP_DELIMITER).toBe('_mcp_');
68
+ });
69
+
70
+ it('should NOT be the old double-underscore format', () => {
71
+ expect(MCP_DELIMITER).not.toBe('__mcp__');
72
+ });
73
+ });
74
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Shared HITL (Human-in-the-Loop) constants and enums.
3
+ *
4
+ * Single source of truth for approval-related values consumed by
5
+ * the agents library, ranger backend, and browser extension.
6
+ *
7
+ * @module approval/constants
8
+ */
9
+
10
+ // ============================================================================
11
+ // Execution Context
12
+ // ============================================================================
13
+
14
+ /**
15
+ * Execution context determines whether HITL approval is required.
16
+ *
17
+ * - `INTERACTIVE`: User is present in the chat session — approval prompts are shown.
18
+ * - `SCHEDULED`: Automated/scheduled execution — all approvals are auto-granted.
19
+ */
20
+ export enum ExecutionContext {
21
+ INTERACTIVE = 'interactive',
22
+ SCHEDULED = 'scheduled',
23
+ }
24
+
25
+ // ============================================================================
26
+ // Approval Policy
27
+ // ============================================================================
28
+
29
+ /**
30
+ * Static approval policy values.
31
+ *
32
+ * - `ALWAYS`: Every tool call requires approval (strictest).
33
+ * - `NEVER`: No tool call requires approval (used for auto-approved tools).
34
+ */
35
+ export enum ApprovalPolicy {
36
+ ALWAYS = 'always',
37
+ NEVER = 'never',
38
+ }
39
+
40
+ // ============================================================================
41
+ // Approval Tier
42
+ // ============================================================================
43
+
44
+ /**
45
+ * Three-tier policy evaluation pipeline.
46
+ * Higher tiers are checked first; once a tier matches, lower tiers are skipped.
47
+ *
48
+ * - `AUTO_APPROVE`: Built-in tools that always skip approval (Tier 1).
49
+ * - `RULE_BASED`: Per-tool argument-aware rules (Tier 2).
50
+ * - `KEYWORD_FALLBACK`: Token-based keyword matching on tool name (Tier 3).
51
+ */
52
+ export enum ApprovalTier {
53
+ AUTO_APPROVE = 'auto_approve',
54
+ RULE_BASED = 'rule_based',
55
+ KEYWORD_FALLBACK = 'keyword_fallback',
56
+ }
57
+
58
+ // ============================================================================
59
+ // Risk Level
60
+ // ============================================================================
61
+
62
+ /**
63
+ * Risk classification for tool calls.
64
+ * Used for audit logging and future UI customization (e.g., showing risk badges).
65
+ *
66
+ * - `NONE`: Read-only, no side effects (search, list, get).
67
+ * - `LOW`: Minor side effects (bookmark, star, mark as read).
68
+ * - `MEDIUM`: User-specific mutations (send email to known contact).
69
+ * - `HIGH`: Destructive or irreversible (delete, modify critical data).
70
+ * - `CRITICAL`: Multi-system impact (transfer funds, delete account).
71
+ */
72
+ export enum RiskLevel {
73
+ NONE = 'none',
74
+ LOW = 'low',
75
+ MEDIUM = 'medium',
76
+ HIGH = 'high',
77
+ CRITICAL = 'critical',
78
+ }
79
+
80
+ // ============================================================================
81
+ // Action Category
82
+ // ============================================================================
83
+
84
+ /**
85
+ * Semantic category for tool actions.
86
+ * Maps action keywords to high-level intent for risk classification.
87
+ */
88
+ export enum ActionCategory {
89
+ READ = 'read',
90
+ WRITE = 'write',
91
+ DELETE = 'delete',
92
+ SEND = 'send',
93
+ EXECUTE = 'execute',
94
+ FINANCIAL = 'financial',
95
+ ACCOUNT = 'account',
96
+ }
97
+
98
+ // ============================================================================
99
+ // MCP Delimiter
100
+ // ============================================================================
101
+
102
+ /**
103
+ * Delimiter used in MCP tool names to separate the tool name from the server key.
104
+ *
105
+ * Example: `send_email_mcp_outlook` → tool = `send_email`, server = `outlook`
106
+ *
107
+ * Must match `Constants.mcp_delimiter` in `@ranger/data-provider`.
108
+ */
109
+ export const MCP_DELIMITER = '_mcp_';
@@ -217,8 +217,12 @@ export type ProgrammaticCache = { toolMap: ToolMap; toolDefs: LCTool[] };
217
217
  * Execution context determines whether HITL approval is required.
218
218
  * - 'interactive': User is present in the chat, approval prompts are shown.
219
219
  * - 'scheduled': Automated/scheduled execution, approval is auto-granted.
220
+ *
221
+ * Re-exported from approval/constants for convenience.
222
+ * @see ExecutionContext enum in approval/constants.ts
220
223
  */
221
- export type ExecutionContext = 'interactive' | 'scheduled' | 'browser';
224
+ export type ExecutionContext =
225
+ `${import('../tools/approval/constants').ExecutionContext}`;
222
226
 
223
227
  /**
224
228
  * Policy for when a tool requires human approval.
@@ -23,7 +23,9 @@ export const FILE_MANIFEST_PREFIX = '[Conversation Files]';
23
23
  * @param manifest - Array of file metadata entries
24
24
  * @returns Formatted text block, or empty string if manifest is empty/undefined
25
25
  */
26
- export function buildFileManifestBlock(manifest: FileManifestEntry[] | undefined): string {
26
+ export function buildFileManifestBlock(
27
+ manifest: FileManifestEntry[] | undefined
28
+ ): string {
27
29
  if (!manifest || manifest.length === 0) {
28
30
  return '';
29
31
  }