@illuma-ai/agents 1.1.6 → 1.1.8
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/dist/cjs/agents/AgentContext.cjs +1 -0
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/events.cjs +1 -0
- package/dist/cjs/events.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +11 -8
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +1 -0
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +1 -0
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/providers.cjs +1 -0
- package/dist/cjs/llm/providers.cjs.map +1 -1
- package/dist/cjs/main.cjs +35 -13
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/content.cjs +1 -0
- package/dist/cjs/messages/content.cjs.map +1 -1
- package/dist/cjs/messages/core.cjs +1 -0
- package/dist/cjs/messages/core.cjs.map +1 -1
- package/dist/cjs/messages/dedup.cjs +1 -0
- package/dist/cjs/messages/dedup.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +1 -0
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +1 -0
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/messages/tools.cjs +1 -0
- package/dist/cjs/messages/tools.cjs.map +1 -1
- package/dist/cjs/run.cjs +1 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/schemas/validate.cjs +1 -0
- package/dist/cjs/schemas/validate.cjs.map +1 -1
- package/dist/cjs/splitStream.cjs +1 -0
- package/dist/cjs/splitStream.cjs.map +1 -1
- package/dist/cjs/stream.cjs +1 -0
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/AskUser.cjs +1 -0
- package/dist/cjs/tools/AskUser.cjs.map +1 -1
- package/dist/cjs/tools/CodeExecutor.cjs +1 -0
- package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -0
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +12 -8
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/ToolSearch.cjs +1 -0
- package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
- package/dist/cjs/tools/approval/constants.cjs +107 -0
- package/dist/cjs/tools/approval/constants.cjs.map +1 -0
- package/dist/cjs/tools/handlers.cjs +1 -0
- package/dist/cjs/tools/handlers.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +1 -0
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/utils/fileManifest.cjs.map +1 -1
- package/dist/cjs/utils/handlers.cjs +1 -0
- package/dist/cjs/utils/handlers.cjs.map +1 -1
- package/dist/cjs/utils/llm.cjs +1 -0
- package/dist/cjs/utils/llm.cjs.map +1 -1
- package/dist/cjs/utils/title.cjs +1 -0
- package/dist/cjs/utils/title.cjs.map +1 -1
- package/dist/cjs/utils/toolCallContinuation.cjs +1 -0
- package/dist/cjs/utils/toolCallContinuation.cjs.map +1 -1
- package/dist/cjs/utils/toolDiscoveryCache.cjs +1 -0
- package/dist/cjs/utils/toolDiscoveryCache.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +1 -0
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/events.mjs +1 -0
- package/dist/esm/events.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +11 -8
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +1 -0
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +1 -0
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/providers.mjs +1 -0
- package/dist/esm/llm/providers.mjs.map +1 -1
- package/dist/esm/main.mjs +1 -0
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/messages/content.mjs +1 -0
- package/dist/esm/messages/content.mjs.map +1 -1
- package/dist/esm/messages/core.mjs +1 -0
- package/dist/esm/messages/core.mjs.map +1 -1
- package/dist/esm/messages/dedup.mjs +1 -0
- package/dist/esm/messages/dedup.mjs.map +1 -1
- package/dist/esm/messages/format.mjs +1 -0
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs +1 -0
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/messages/tools.mjs +1 -0
- package/dist/esm/messages/tools.mjs.map +1 -1
- package/dist/esm/run.mjs +1 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/schemas/validate.mjs +1 -0
- package/dist/esm/schemas/validate.mjs.map +1 -1
- package/dist/esm/splitStream.mjs +1 -0
- package/dist/esm/splitStream.mjs.map +1 -1
- package/dist/esm/stream.mjs +1 -0
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/AskUser.mjs +1 -0
- package/dist/esm/tools/AskUser.mjs.map +1 -1
- package/dist/esm/tools/CodeExecutor.mjs +1 -0
- package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -0
- package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +12 -8
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/ToolSearch.mjs +1 -0
- package/dist/esm/tools/ToolSearch.mjs.map +1 -1
- package/dist/esm/tools/approval/constants.mjs +105 -0
- package/dist/esm/tools/approval/constants.mjs.map +1 -0
- package/dist/esm/tools/handlers.mjs +1 -0
- package/dist/esm/tools/handlers.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +1 -0
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/utils/fileManifest.mjs.map +1 -1
- package/dist/esm/utils/handlers.mjs +1 -0
- package/dist/esm/utils/handlers.mjs.map +1 -1
- package/dist/esm/utils/llm.mjs +1 -0
- package/dist/esm/utils/llm.mjs.map +1 -1
- package/dist/esm/utils/title.mjs +1 -0
- package/dist/esm/utils/title.mjs.map +1 -1
- package/dist/esm/utils/toolCallContinuation.mjs +1 -0
- package/dist/esm/utils/toolCallContinuation.mjs.map +1 -1
- package/dist/esm/utils/toolDiscoveryCache.mjs +1 -0
- package/dist/esm/utils/toolDiscoveryCache.mjs.map +1 -1
- package/dist/types/common/index.d.ts +1 -0
- package/dist/types/tools/approval/constants.d.ts +79 -0
- package/dist/types/types/tools.d.ts +4 -2
- package/package.json +1 -1
- package/src/common/index.ts +1 -0
- package/src/graphs/Graph.ts +42 -27
- package/src/graphs/gapFeatures.test.ts +65 -22
- package/src/tools/ToolNode.ts +11 -12
- package/src/tools/__tests__/ToolApproval.test.ts +100 -46
- package/src/tools/__tests__/ToolNode.hitl.test.ts +3 -2
- package/src/tools/approval/__tests__/constants.test.ts +74 -0
- package/src/tools/approval/constants.ts +109 -0
- package/src/types/tools.ts +4 -2
- 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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
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 {
|
|
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 (
|
|
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 {
|
|
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({
|
|
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 } =
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
{
|
|
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(
|
|
1005
|
-
expect(
|
|
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
|
});
|
package/src/tools/ToolNode.ts
CHANGED
|
@@ -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
|
|
183
|
-
if (executionContext ===
|
|
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
|
-
|
|
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
|
-
?
|
|
1007
|
-
|
|
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('
|
|
268
|
-
//
|
|
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: '
|
|
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: '
|
|
318
|
-
id: '
|
|
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:
|
|
297
|
+
'Echo: scheduled test'
|
|
330
298
|
);
|
|
331
299
|
});
|
|
332
300
|
|
|
333
|
-
test('
|
|
334
|
-
// Even with an explicit 'always' override for the tool,
|
|
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: '
|
|
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: '
|
|
354
|
-
id: '
|
|
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:
|
|
333
|
+
'Result for: scheduled override test'
|
|
366
334
|
);
|
|
367
335
|
});
|
|
368
336
|
|
|
369
|
-
test('interactive context still requires approval (
|
|
370
|
-
// Verify that 'interactive' context DOES require approval (contrasts with
|
|
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
|
|
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: '
|
|
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_';
|
package/src/types/tools.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { StructuredToolInterface } from '@langchain/core/tools';
|
|
|
3
3
|
import type { RunnableToolLike } from '@langchain/core/runnables';
|
|
4
4
|
import type { ToolCall } from '@langchain/core/messages/tool';
|
|
5
5
|
import type { ToolErrorData } from './stream';
|
|
6
|
-
import { EnvVar } from '@/common';
|
|
6
|
+
import { EnvVar, type ExecutionContext } from '@/common';
|
|
7
7
|
|
|
8
8
|
/** Replacement type for `import type { ToolCall } from '@langchain/core/messages/tool'` in order to have stringified args typed */
|
|
9
9
|
export type CustomToolCall = {
|
|
@@ -217,8 +217,10 @@ 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
|
+
* @see ExecutionContext enum in common/index.ts → tools/approval/constants.ts
|
|
222
|
+
* Note: ExecutionContext is exported from common/index.ts, not re-exported here to avoid TS2308.
|
|
220
223
|
*/
|
|
221
|
-
export type ExecutionContext = 'interactive' | 'scheduled' | 'browser';
|
|
222
224
|
|
|
223
225
|
/**
|
|
224
226
|
* 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(
|
|
26
|
+
export function buildFileManifestBlock(
|
|
27
|
+
manifest: FileManifestEntry[] | undefined
|
|
28
|
+
): string {
|
|
27
29
|
if (!manifest || manifest.length === 0) {
|
|
28
30
|
return '';
|
|
29
31
|
}
|